ニューラルアーキテクチャ探索(NAS)の手法を比較する

ニューラルアーキテクチャ探索(Neural Architecture Search, NAS)は、最適なニューラルネットワーク構造を自動的に発見する技術です。人手によるアーキテクチャ設計の労力を削減し、特定のタスクに最適化されたモデルを効率的に見つけることができます。

本記事では、NASの理論的背景から実装まで詳しく解説します。

本記事の内容

  • NASの基本概念と3つの構成要素
  • 主要な探索戦略の数学的定式化
  • Pythonでの簡易NAS実装

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

NASとは

基本的なアイデア

従来、ニューラルネットワークのアーキテクチャ(層の数、各層のユニット数、接続パターンなど)は、研究者が試行錯誤で設計していました。NASは、この設計プロセスを自動化します。

NASの3つの構成要素

NASは以下の3つの要素で構成されます:

  1. 探索空間(Search Space): 候補となるアーキテクチャの集合
  2. 探索戦略(Search Strategy): 探索空間からアーキテクチャを選ぶ方法
  3. 性能評価戦略(Performance Estimation): アーキテクチャの良さを評価する方法

探索空間の設計

セルベースの探索空間

効率的なNASでは、ネットワーク全体ではなく「セル」と呼ばれる繰り返し単位を探索します。

セルは有向非巡回グラフ(DAG)として表現されます: – ノード:特徴マップ – エッジ:操作(畳み込み、プーリングなど)

操作の候補

典型的な操作の候補:

操作 説明
3×3 Conv 3×3畳み込み
5×5 Conv 5×5畳み込み
3×3 SepConv 3×3 Separable畳み込み
3×3 DilConv 3×3 Dilated畳み込み
3×3 MaxPool 3×3最大プーリング
3×3 AvgPool 3×3平均プーリング
Skip スキップ接続(恒等写像)
Zero 接続なし

数学的表現

$N$ 個のノードを持つセルを考えます。ノード $i$ の出力 $x^{(i)}$ は:

$$ x^{(i)} = \sum_{j < i} o^{(j, i)}(x^{(j)}) $$

ここで $o^{(j, i)}$ はノード $j$ から $i$ への操作です。

探索空間は、各エッジ $(j, i)$ に対して操作 $o$ を選択する組み合わせで定義されます。

探索戦略

1. 強化学習ベース(NASNet)

コントローラ(RNN)がアーキテクチャを生成し、その性能を報酬として学習します。

状態:生成途中のアーキテクチャ 行動:次の操作を選択 報酬:検証精度

$$ J(\theta) = \mathbb{E}_{a \sim \pi_\theta}[R(a)] $$

ここで $\theta$ はコントローラのパラメータ、$a$ は生成されたアーキテクチャ、$R(a)$ はその報酬です。

REINFORCE勾配:

$$ \nabla_\theta J(\theta) = \mathbb{E}_{a \sim \pi_\theta}[(R(a) – b) \nabla_\theta \log \pi_\theta(a)] $$

$b$ は分散を減らすためのベースラインです。

2. 進化的アルゴリズム(AmoebaNet)

アーキテクチャを個体として、進化的操作で探索します:

  1. 初期化: ランダムなアーキテクチャ集団を生成
  2. 評価: 各個体の適応度(精度)を計算
  3. 選択: 適応度の高い個体を選択
  4. 突然変異: アーキテクチャに小さな変更を加える
  5. 2-4を繰り返す

3. 勾配ベース(DARTS)

連続緩和により、離散的なアーキテクチャ選択を微分可能にします。

各エッジ $(i, j)$ の操作を、候補操作の重み付き和で近似:

$$ \bar{o}^{(i, j)}(x) = \sum_{o \in \mathcal{O}} \frac{\exp(\alpha_o^{(i,j)})}{\sum_{o’ \in \mathcal{O}} \exp(\alpha_{o’}^{(i,j)})} \cdot o(x) $$

$\alpha$ は各操作の重み(アーキテクチャパラメータ)です。

探索後、各エッジで最も重みの大きい操作を選択:

$$ o^{(i, j)} = \arg\max_{o \in \mathcal{O}} \alpha_o^{(i, j)} $$

DARTSの最適化

DARTSは2レベルの最適化問題として定式化されます:

$$ \min_\alpha \mathcal{L}_{\text{val}}(w^*(\alpha), \alpha) $$

$$ \text{s.t.} \quad w^*(\alpha) = \arg\min_w \mathcal{L}_{\text{train}}(w, \alpha) $$

これを交互最適化で解きます: 1. $\alpha$ を固定して $w$ を更新(訓練データで) 2. $w$ を固定して $\alpha$ を更新(検証データで)

性能評価戦略

1. フル学習

各候補アーキテクチャを最後まで学習して評価。正確だが計算コストが高い。

2. 早期打ち切り

学習の途中(例: 10エポック)で評価。コスト削減だが、最終性能と相関が低い場合も。

3. Weight Sharing

全ての候補アーキテクチャで重みを共有。DARTSなど微分可能NASで使用。

4. 予測器ベース

少数のサンプルで学習した予測モデルで性能を推定。

Pythonでの実装

シンプルなランダム探索NAS

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import matplotlib.pyplot as plt
from itertools import product

# 探索空間の定義
class SearchSpace:
    """探索空間の定義"""

    def __init__(self):
        # 各層のオプション
        self.hidden_dims_options = [32, 64, 128, 256]
        self.num_layers_options = [1, 2, 3, 4]
        self.activation_options = ['relu', 'gelu', 'silu']
        self.dropout_options = [0.0, 0.1, 0.2, 0.3]

    def sample_architecture(self):
        """ランダムにアーキテクチャをサンプリング"""
        num_layers = np.random.choice(self.num_layers_options)
        hidden_dims = [np.random.choice(self.hidden_dims_options) for _ in range(num_layers)]
        activation = np.random.choice(self.activation_options)
        dropout = np.random.choice(self.dropout_options)

        return {
            'num_layers': num_layers,
            'hidden_dims': hidden_dims,
            'activation': activation,
            'dropout': dropout
        }

def get_activation(name):
    """活性化関数を取得"""
    if name == 'relu':
        return nn.ReLU()
    elif name == 'gelu':
        return nn.GELU()
    elif name == 'silu':
        return nn.SiLU()
    else:
        raise ValueError(f"Unknown activation: {name}")

class DynamicNet(nn.Module):
    """動的に構造を変更できるネットワーク"""

    def __init__(self, input_dim, num_classes, config):
        super().__init__()

        layers = []
        prev_dim = input_dim

        for hidden_dim in config['hidden_dims']:
            layers.append(nn.Linear(prev_dim, hidden_dim))
            layers.append(get_activation(config['activation']))
            if config['dropout'] > 0:
                layers.append(nn.Dropout(config['dropout']))
            prev_dim = hidden_dim

        layers.append(nn.Linear(prev_dim, num_classes))
        self.net = nn.Sequential(*layers)

    def forward(self, x):
        return self.net(x)

def evaluate_architecture(config, train_loader, val_loader, input_dim, num_classes,
                          n_epochs=10, lr=0.001):
    """アーキテクチャを評価"""
    model = DynamicNet(input_dim, num_classes, config)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr)

    # 学習
    model.train()
    for epoch in range(n_epochs):
        for X_batch, y_batch in train_loader:
            optimizer.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer.step()

    # 検証
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for X_batch, y_batch in val_loader:
            outputs = model(X_batch)
            _, predicted = torch.max(outputs, 1)
            total += y_batch.size(0)
            correct += (predicted == y_batch).sum().item()

    accuracy = correct / total

    # パラメータ数
    n_params = sum(p.numel() for p in model.parameters())

    return accuracy, n_params

def random_search_nas(search_space, train_loader, val_loader,
                      input_dim, num_classes, n_trials=20):
    """ランダム探索によるNAS"""
    results = []

    for trial in range(n_trials):
        config = search_space.sample_architecture()
        accuracy, n_params = evaluate_architecture(
            config, train_loader, val_loader, input_dim, num_classes
        )

        results.append({
            'config': config,
            'accuracy': accuracy,
            'n_params': n_params,
            'trial': trial
        })

        print(f"Trial {trial + 1}/{n_trials}: "
              f"Accuracy = {accuracy:.4f}, Params = {n_params:,}, "
              f"Config = {config}")

    return results

# データ準備
def create_dataset(n_samples=3000, input_dim=50, num_classes=5):
    np.random.seed(42)

    X = np.random.randn(n_samples, input_dim).astype(np.float32)
    W = np.random.randn(input_dim, num_classes).astype(np.float32)
    logits = X @ W + np.random.randn(n_samples, num_classes).astype(np.float32) * 0.3
    y = np.argmax(logits, axis=1)

    # 分割
    train_split = int(0.6 * n_samples)
    val_split = int(0.8 * n_samples)

    X_train, y_train = X[:train_split], y[:train_split]
    X_val, y_val = X[train_split:val_split], y[train_split:val_split]
    X_test, y_test = X[val_split:], y[val_split:]

    return (X_train, y_train), (X_val, y_val), (X_test, y_test)

# 実験
(X_train, y_train), (X_val, y_val), (X_test, y_test) = create_dataset()

train_dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))
val_dataset = TensorDataset(torch.tensor(X_val), torch.tensor(y_val))
test_dataset = TensorDataset(torch.tensor(X_test), torch.tensor(y_test))

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

# NAS実行
print("Running Random Search NAS...")
search_space = SearchSpace()
results = random_search_nas(
    search_space, train_loader, val_loader,
    input_dim=50, num_classes=5, n_trials=15
)

# 結果の可視化
accuracies = [r['accuracy'] for r in results]
n_params = [r['n_params'] for r in results]

plt.figure(figsize=(12, 4))

plt.subplot(1, 2, 1)
plt.scatter(n_params, accuracies, alpha=0.7, s=100)
plt.xlabel('Number of Parameters')
plt.ylabel('Validation Accuracy')
plt.title('Accuracy vs Model Size')
plt.grid(True, alpha=0.3)

# ベストモデルをハイライト
best_idx = np.argmax(accuracies)
plt.scatter([n_params[best_idx]], [accuracies[best_idx]],
            color='red', s=200, marker='*', label='Best')
plt.legend()

plt.subplot(1, 2, 2)
plt.plot(range(1, len(accuracies) + 1), accuracies, 'o-')
plt.xlabel('Trial')
plt.ylabel('Validation Accuracy')
plt.title('Search Progress')
plt.grid(True, alpha=0.3)

# ベストを累積で表示
best_so_far = np.maximum.accumulate(accuracies)
plt.plot(range(1, len(accuracies) + 1), best_so_far, 'r--', label='Best so far')
plt.legend()

plt.tight_layout()
plt.show()

# ベストモデルの詳細
best_result = results[best_idx]
print(f"\nBest Architecture:")
print(f"  Config: {best_result['config']}")
print(f"  Validation Accuracy: {best_result['accuracy']:.4f}")
print(f"  Parameters: {best_result['n_params']:,}")

簡易DARTS風の実装

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt

class MixedOp(nn.Module):
    """複数の操作の重み付き和"""

    def __init__(self, in_features, out_features):
        super().__init__()
        self.ops = nn.ModuleList([
            nn.Linear(in_features, out_features),                    # Linear
            nn.Sequential(nn.Linear(in_features, out_features),      # Linear + ReLU
                         nn.ReLU()),
            nn.Sequential(nn.Linear(in_features, out_features),      # Linear + GELU
                         nn.GELU()),
            nn.Identity() if in_features == out_features else        # Skip or Zero
                nn.Linear(in_features, out_features),
        ])
        self.n_ops = len(self.ops)

    def forward(self, x, weights):
        """
        Args:
            x: 入力 (batch_size, in_features)
            weights: 各操作の重み (n_ops,)
        """
        return sum(w * op(x) for w, op in zip(weights, self.ops))

class DARTSLikeCell(nn.Module):
    """DARTS風のセル"""

    def __init__(self, in_features, hidden_features, out_features):
        super().__init__()
        self.op1 = MixedOp(in_features, hidden_features)
        self.op2 = MixedOp(hidden_features, out_features)

        # アーキテクチャパラメータ
        self.alpha1 = nn.Parameter(torch.zeros(self.op1.n_ops))
        self.alpha2 = nn.Parameter(torch.zeros(self.op2.n_ops))

    def forward(self, x):
        weights1 = F.softmax(self.alpha1, dim=0)
        weights2 = F.softmax(self.alpha2, dim=0)

        x = self.op1(x, weights1)
        x = self.op2(x, weights2)

        return x

    def get_architecture(self):
        """最終的なアーキテクチャを取得"""
        op_names = ['Linear', 'Linear+ReLU', 'Linear+GELU', 'Skip/Linear']
        arch = {
            'op1': op_names[self.alpha1.argmax().item()],
            'op2': op_names[self.alpha2.argmax().item()],
            'alpha1': F.softmax(self.alpha1, dim=0).detach().numpy(),
            'alpha2': F.softmax(self.alpha2, dim=0).detach().numpy(),
        }
        return arch

class DARTSLikeModel(nn.Module):
    """DARTS風のモデル"""

    def __init__(self, input_dim, hidden_dim, num_classes):
        super().__init__()
        self.cell = DARTSLikeCell(input_dim, hidden_dim, hidden_dim)
        self.classifier = nn.Linear(hidden_dim, num_classes)

    def forward(self, x):
        x = self.cell(x)
        return self.classifier(x)

    def arch_parameters(self):
        """アーキテクチャパラメータを返す"""
        return [self.cell.alpha1, self.cell.alpha2]

    def weight_parameters(self):
        """重みパラメータを返す"""
        for name, param in self.named_parameters():
            if 'alpha' not in name:
                yield param

def train_darts(model, train_loader, val_loader, n_epochs=30, lr_w=0.001, lr_a=0.001):
    """DARTSスタイルの学習"""
    criterion = nn.CrossEntropyLoss()

    # 重みとアーキテクチャパラメータで別のオプティマイザ
    optimizer_w = optim.Adam(model.weight_parameters(), lr=lr_w)
    optimizer_a = optim.Adam(model.arch_parameters(), lr=lr_a)

    history = {'train_loss': [], 'val_loss': [], 'alpha1': [], 'alpha2': []}

    for epoch in range(n_epochs):
        # === 訓練データで重みを更新 ===
        model.train()
        train_loss = 0
        for X_batch, y_batch in train_loader:
            optimizer_w.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer_w.step()
            train_loss += loss.item()

        # === 検証データでアーキテクチャパラメータを更新 ===
        val_loss = 0
        for X_batch, y_batch in val_loader:
            optimizer_a.zero_grad()
            outputs = model(X_batch)
            loss = criterion(outputs, y_batch)
            loss.backward()
            optimizer_a.step()
            val_loss += loss.item()

        avg_train_loss = train_loss / len(train_loader)
        avg_val_loss = val_loss / len(val_loader)

        history['train_loss'].append(avg_train_loss)
        history['val_loss'].append(avg_val_loss)

        arch = model.cell.get_architecture()
        history['alpha1'].append(arch['alpha1'].copy())
        history['alpha2'].append(arch['alpha2'].copy())

        if (epoch + 1) % 5 == 0:
            print(f"Epoch {epoch+1}/{n_epochs}: "
                  f"Train Loss = {avg_train_loss:.4f}, Val Loss = {avg_val_loss:.4f}")
            print(f"  Op1: {arch['op1']} (weights: {arch['alpha1'].round(3)})")
            print(f"  Op2: {arch['op2']} (weights: {arch['alpha2'].round(3)})")

    return history

# DARTS実験
np.random.seed(42)
torch.manual_seed(42)

# データ準備(前の例と同じ)
(X_train, y_train), (X_val, y_val), (X_test, y_test) = create_dataset()

train_dataset = TensorDataset(torch.tensor(X_train), torch.tensor(y_train))
val_dataset = TensorDataset(torch.tensor(X_val), torch.tensor(y_val))

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)

# モデル
model = DARTSLikeModel(input_dim=50, hidden_dim=64, num_classes=5)

print("Training DARTS-like model...")
history = train_darts(model, train_loader, val_loader, n_epochs=30)

# 結果の可視化
fig, axes = plt.subplots(1, 3, figsize=(15, 4))

# 損失の推移
axes[0].plot(history['train_loss'], label='Train')
axes[0].plot(history['val_loss'], label='Validation')
axes[0].set_xlabel('Epoch')
axes[0].set_ylabel('Loss')
axes[0].set_title('Training Progress')
axes[0].legend()
axes[0].grid(True, alpha=0.3)

# Op1のアーキテクチャパラメータの推移
alpha1_history = np.array(history['alpha1'])
op_names = ['Linear', 'Linear+ReLU', 'Linear+GELU', 'Skip']
for i, name in enumerate(op_names):
    axes[1].plot(alpha1_history[:, i], label=name)
axes[1].set_xlabel('Epoch')
axes[1].set_ylabel('Weight')
axes[1].set_title('Op1 Architecture Weights')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# Op2のアーキテクチャパラメータの推移
alpha2_history = np.array(history['alpha2'])
for i, name in enumerate(op_names):
    axes[2].plot(alpha2_history[:, i], label=name)
axes[2].set_xlabel('Epoch')
axes[2].set_ylabel('Weight')
axes[2].set_title('Op2 Architecture Weights')
axes[2].legend()
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# 最終アーキテクチャ
print("\nFinal Architecture:")
arch = model.cell.get_architecture()
print(f"  Op1: {arch['op1']}")
print(f"  Op2: {arch['op2']}")

NASの実践的な考慮事項

計算コスト

手法 GPU日数(CIFAR-10)
NASNet (RL) 2000
AmoebaNet (進化的) 3150
DARTS (勾配) 1.5
ProxylessNAS 4

探索空間の設計指針

  1. タスクに適した操作を選択: 画像タスクなら畳み込み系、系列タスクならAttention系
  2. 適度な大きさ: 大きすぎると探索が困難、小さすぎると良いアーキテクチャを見逃す
  3. 良い帰納バイアスを含める: ResNetのスキップ接続など

評価のトリックス

  1. 重み共有: 計算効率は上がるが、性能の相関が下がることも
  2. 早期打ち切り: 速いが、最終性能と相関が低い場合がある
  3. プロキシタスク: 小さいデータセットや低解像度で探索

まとめ

本記事では、ニューラルアーキテクチャ探索(NAS)について解説しました。

  • NASは探索空間、探索戦略、性能評価の3要素で構成される
  • 探索戦略には強化学習、進化的アルゴリズム、勾配法がある
  • DARTSは連続緩和により効率的な勾配ベース探索を実現
  • 実用的には計算コストと性能のトレードオフが重要

次のステップとして、以下の記事も参考にしてください。