主要指標
  • CNNの核心設計は、HubelとWieselによる猫の視覚野の研究に触発されたものです[1]——局所受容野と階層的特徴抽出の概念は1962年以来不変です
  • LeNet-5[2]からResNet[6]、EfficientNet[7]に至るまで、CNNアーキテクチャの進化は一つの中心的問いを反映しています:深さ・幅・効率の最適バランスをどう見つけるか
  • CNNは画像に限定されません——TextCNN[8]は1D畳み込みがテキスト分類に高速かつ効果的であることを示し、今日でも産業用NLP分類のコストパフォーマンスに優れた選択肢です
  • 本記事には2つのGoogle Colabハンズオンラボを収録:MNIST手書き数字認識と特徴マップ可視化、およびTextCNNによるテキスト感情分類

1. 生物学的視覚から人工視覚へ:CNNの起源

1962年、神経科学者のDavid HubelとTorsten Wieselは麻酔した猫を対象に歴史を変える実験を行いました[1]。彼らは猫の視覚野に2種類の細胞を発見しました:単純細胞(特定方向のエッジに感受性を持つ)と複雑細胞(ある程度の位置不変性を示す)です。さらに重要なのは、視覚情報が階層的に処理されることです——下層がエッジを検出し、中層がそれらを形状に組み合わせ、上層が物体を認識します。

この発見は畳み込みニューラルネットワークの3つの核心設計原理に直接インスピレーションを与えました:

1998年、Yann LeCunらはこれらの原理をエンジニアリングし[2]LeNet-5を生み出しました——わずか5層の畳み込みネットワークで手書き数字認識において99.2%の精度を達成し、米国郵政公社で全国の手書き小切手の10%の処理に使用されました。LeNet-5は深い洞察を実証しました:良い帰納的バイアスは、より多くのパラメータよりも重要である。

しかし、CNNの真の爆発は2012年まで待たなければなりませんでした。Krizhevsky、Sutskever、HintonのAlexNet[3]はImageNet大規模画像認識チャレンジを圧倒的な差で制しました——Top-5エラー率16.4%、対する2位の従来手法は26.2%。この10パーセントポイントの差はコンピュータビジョンコミュニティ全体に衝撃を与え、正式にディープラーニングの時代を開幕しました[16]

2. 核心メカニズム:畳み込み、プーリング、特徴階層

2.1 畳み込み演算:局所特徴検出

本質的に、畳み込みはスライディングウィンドウによるパターンマッチング演算です。3x3の畳み込みカーネル(フィルタとも呼ばれる)が入力画像上をスライドし、各位置でカーネルと対応領域の内積を計算して出力値を生成します。カーネルが画像全体を走査すると、その結果が特徴マップとなります。

畳み込み層は通常、複数のカーネルを含んでいます——各カーネルは異なるパターンの検出を学習します。例えば、第1層の6つのカーネルは、水平エッジ、垂直エッジ、45度の斜め線、135度の斜め線、コーナー、曲線をそれぞれ検出するように学習する可能性があります。

なぜ畳み込みは全結合層より効果的なのでしょうか?Goodfellowらの名著[10]は3つの主要な利点を挙げています:

2.2 プーリング演算:空間圧縮と不変性

プーリングは特徴マップの空間的なダウンサンプリングを行います——最も一般的な2x2マックスプーリングは、4つのピクセルから最大値を取り、特徴マップの高さと幅を半分に縮小します。

プーリングの重要性はデータ圧縮にとどまりません——局所平行移動不変性を導入します。2x2領域内のどの正確な位置で特徴が検出されても、プーリング後の出力は同じです。これによりCNNは小さなシフトや変形に対するロバスト性を獲得します。

2.3 階層的特徴:エッジから物体へ

ZeilerとFergusの画期的な可視化研究[9]は、CNNの各層で学習される特徴が明確な階層構造を形成することを明らかにしました:

この階層構造はHubelとWieselが発見した階層的視覚処理メカニズムと驚くほど一致しています——CNNはある意味、生物学的視覚の情報処理戦略を「再発明」したのです。

3. アーキテクチャの進化:LeNetからConvNeXtへ

アーキテクチャ深さ主要イノベーションImageNet Top-5エラー
1998LeNet-5[2]5基本的な畳み込み+プーリングパラダイム-- (MNIST 0.8%)
2012AlexNet[3]8ReLU、Dropout、GPUトレーニング16.4%
2014VGGNet[4]16-19小さな3x3カーネルの均一スタッキング7.3%
2014GoogLeNet[5]22Inceptionマルチスケール並列畳み込み6.7%
2015ResNet[6]152残差接続(スキップ接続)3.6%
2017MobileNet[12]28Depthwise Separable Convolutions-- (効率的デプロイメント)
2019EfficientNet[7]自動スケーリングCompound Scaling(深さ x 幅 x 解像度)2.9% (B7)
2022ConvNeXt[15]可変Transformerの設計原則でCNNを現代化ViTと同等

いくつかの主要なターニングポイントを詳しく見てみましょう:

VGGNetの洞察[4]3x3カーネル2つのスタックは、5x5カーネル1つと同じ有効受容野を持ちますが、パラメータ数は少なく(18 vs. 25)、間にさらに非線形活性化が入ります。結論:浅くて広いよりも、深くて狭い方が良い

ResNetのブレイクスルー[6]ネットワークが20層を超えると、訓練誤差がむしろ増加しました——過学習のためではなく、劣化問題のためです。ResNetの残差接続(F(x) + x)がこれを解決しました:直接マッピングではなく「残差」を学習することで、勾配が152層を通してスムーズに流れるようになりました。これはディープラーニングにおける最も重要なアーキテクチャイノベーションの一つです。

1x1畳み込み[14]一見パラドックスに思えます——1x1カーネルには空間的受容野がありません。しかしチャネル次元に沿った線形結合を実行し、次元削減や拡張を可能にします。GoogLeNetのInceptionモジュールとResNetのボトルネック構造の両方が、1x1畳み込みを使用して計算コストを制御しています。

Batch Normalization[13]各層の出力を正規化することでトレーニングプロセスを安定させ、より大きな学習率の使用を可能にします。BNは非常に効果的であり、現代のCNNのほぼすべてが標準コンポーネントとして含んでいます。

4. コンピュータビジョンにおけるCNNの主要アプリケーション

コンピュータビジョンにおけるCNNのアプリケーションはあらゆるサブドメインに浸透しています:

5. テキストAIのためのCNN:TextCNN

2014年、Yoon Kim[8]は簡潔でありながら強力な論文を発表し、畳み込みニューラルネットワークは画像を見るだけでなくテキストも読めることを実証しました。TextCNNの設計は非常にエレガントです:

  1. 入力表現:文中の各単語が単語埋め込みベクトル(例えば300次元)に変換され、文全体がn x 300の行列となります——テキストを画像のように扱います
  2. 1D畳み込み:異なるサイズのカーネル(例えば3語、4語、5語のウィンドウ)が文をスライドし、様々な長さのn-gramパターンをキャプチャします
  3. グローバルマックスプーリング:各カーネルの出力から最大値を取ります——文の長さに関係なく、固定長ベクトルに圧縮されます
  4. 全結合分類:すべてのカーネルの出力を結合し、Softmax付きのFC層に通して分類します

TextCNNの直感:異なるサイズのカーネルは、テキスト内の異なる長さの「キーフレーズ」を検索するようなものです。3-gramカーネルは「not very good」をキャプチャし、4-gramカーネルは「waste of my time」をキャプチャするかもしれません——これらの局所パターンを組み合わせることで、感情の極性を判定するのに十分です。

TransformerとBERTが多くのNLPタスクでCNNを上回っていますが、TextCNNは産業界で依然として代替不可能な地位を保っています:推論速度が10〜100倍高速(Self-Attentionの二次計算量がない)、デプロイメントのリソース要件が低く、短文分類の精度はBERTに迫ります。

6. インタラクティブ3D可視化:CNNが手書き数字を認識する仕組み

CNNを理解する最善の方法は、実際に「見る」ことです。Three.jsを使用してインタラクティブ3D可視化を構築し、LeNetスタイルのCNNがMNIST手書き数字を処理する過程を示します:

この可視化では、以下の操作を想像できます:

主要な観察ポイント:

  1. 特徴マップの階層的進行:入力層は鮮明な28x28ピクセル画像です。Conv1は6枚のぼやけた特徴マップを生成します(エッジ検出)。Conv2は16枚のより小さくより抽象的な特徴マップを生成します(パターンの組み合わせ)
  2. 空間次元の圧縮:各プーリング演算後に特徴マップは半分に縮小されます(28 -> 14 -> 7 -> ...)。一方でチャネル数は増加します(1 -> 6 -> 16)——空間情報の損失をより多くの「種類」の特徴で補償しています
  3. 空間から意味への移行:畳み込み層は空間構造を保持しますが、全結合層はそれを破壊します——FC層はもはや特徴が「どこに」あるかは気にせず、「何であるか」だけを気にします

7. ハンズオンラボ1:MNIST手書き数字認識と特徴マップ可視化(Google Colab)

このラボではCNNをゼロから構築してMNISTでトレーニングし、各層の畳み込みカーネルと特徴マップを可視化します——「CNNが何を学習したか」を直接確認できます。

Google Colabを開く(CPUで十分、GPUの方が高速)、新しいNotebookを作成し、以下のコードブロックを順番に貼り付けてください:

7.1 データ準備とベースラインテスト

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
import numpy as np

# MNISTデータセットの読み込み
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, transform=transform)

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

# トレーニングサンプル画像の可視化
fig, axes = plt.subplots(2, 8, figsize=(16, 4))
for i in range(16):
    img, label = train_dataset[i]
    ax = axes[i // 8][i % 8]
    ax.imshow(img.squeeze(), cmap='gray')
    ax.set_title(f'{label}', fontsize=12)
    ax.axis('off')
plt.suptitle('MNIST Training Samples', fontsize=14)
plt.tight_layout()
plt.show()

print(f"Training set: {len(train_dataset)} images")
print(f"Test set: {len(test_dataset)} images")
print(f"Image shape: {train_dataset[0][0].shape}")

7.2 LeNet-5スタイルCNNの構築

# LeNet-5スタイルCNN(Three.js可視化に対応)
class LeNetCNN(nn.Module):
    def __init__(self):
        super().__init__()
        # Conv1: 1 -> 6チャネル、5x5カーネル
        self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=0)
        # Conv2: 6 -> 16チャネル、5x5カーネル
        self.conv2 = nn.Conv2d(6, 16, kernel_size=5, padding=0)
        # FC層
        self.fc1 = nn.Linear(16 * 4 * 4, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

        # 中間特徴マップの保存(可視化用)
        self.feature_maps = {}

    def forward(self, x):
        # Conv1 + ReLU + MaxPool
        x = self.conv1(x)                    # [B, 6, 24, 24]
        self.feature_maps['conv1'] = x.detach()
        x = F.relu(x)
        x = F.max_pool2d(x, 2)               # [B, 6, 12, 12]
        self.feature_maps['pool1'] = x.detach()

        # Conv2 + ReLU + MaxPool
        x = self.conv2(x)                    # [B, 16, 8, 8]
        self.feature_maps['conv2'] = x.detach()
        x = F.relu(x)
        x = F.max_pool2d(x, 2)               # [B, 16, 4, 4]
        self.feature_maps['pool2'] = x.detach()

        # Flatten + FC
        x = x.view(x.size(0), -1)            # [B, 256]
        x = F.relu(self.fc1(x))              # [B, 120]
        x = F.relu(self.fc2(x))              # [B, 84]
        x = self.fc3(x)                      # [B, 10]
        return x

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = LeNetCNN().to(device)

total_params = sum(p.numel() for p in model.parameters())
print(f"Model: LeNet-5 style CNN")
print(f"Total parameters: {total_params:,}")
print(f"Device: {device}")
print(f"\nArchitecture:")
print(model)

7.3 モデルのトレーニング

# 5エポックのトレーニング
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()

train_losses = []
test_accs = []

for epoch in range(5):
    model.train()
    running_loss = 0
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
        running_loss += loss.item()

    avg_loss = running_loss / len(train_loader)
    train_losses.append(avg_loss)

    # テスト
    model.eval()
    correct = 0
    with torch.no_grad():
        for data, target in test_loader:
            data, target = data.to(device), target.to(device)
            output = model(data)
            pred = output.argmax(dim=1)
            correct += pred.eq(target).sum().item()

    acc = correct / len(test_dataset) * 100
    test_accs.append(acc)
    print(f"Epoch {epoch+1}: loss={avg_loss:.4f}, test_acc={acc:.2f}%")

print(f"\nFinal test accuracy: {test_accs[-1]:.2f}%")

7.4 畳み込みカーネルの可視化

# Conv1の6つの畳み込みカーネルを可視化
conv1_weights = model.conv1.weight.data.cpu()

fig, axes = plt.subplots(1, 6, figsize=(15, 3))
for i in range(6):
    kernel = conv1_weights[i, 0]  # [5, 5]
    axes[i].imshow(kernel, cmap='RdBu_r', vmin=-0.5, vmax=0.5)
    axes[i].set_title(f'Filter {i}', fontsize=11)
    axes[i].axis('off')
plt.suptitle('Conv1 Learned Filters (5x5) — Red = positive weights, Blue = negative weights', fontsize=13)
plt.tight_layout()
plt.show()

# 主要な観察ポイント:
# これらの5x5カーネルはどのようなパターンを学習したか?
# - 水平ストライプパターン -> 水平エッジ検出器
# - 垂直ストライプパターン -> 垂直エッジ検出器
# - 斜めパターン -> 斜めエッジ検出器
# - 明るい中心/暗い周辺 -> ブロブ検出器

7.5 特徴マップの可視化:CNNが「見ている」もの

# テスト画像に対してフォワードパスを実行し、各層の特徴マップを可視化
model.eval()
sample_img, sample_label = test_dataset[0]
sample_input = sample_img.unsqueeze(0).to(device)

with torch.no_grad():
    output = model(sample_input)
    pred = output.argmax(dim=1).item()
    prob = F.softmax(output, dim=1)[0, pred].item()

print(f"True label: {sample_label}, Predicted: {pred} ({prob:.1%})")

# 各層の特徴マップをプロット
fig, axes = plt.subplots(4, 8, figsize=(20, 10))

# 行0:元の入力
axes[0][0].imshow(sample_img.squeeze().cpu(), cmap='gray')
axes[0][0].set_title('Input', fontsize=11)
for j in range(1, 8):
    axes[0][j].axis('off')
axes[0][0].axis('off')

# 行1:Conv1特徴マップ(6チャネル)
conv1_maps = model.feature_maps['conv1'][0].cpu()
for j in range(6):
    axes[1][j].imshow(conv1_maps[j], cmap='viridis')
    axes[1][j].set_title(f'Conv1-{j}', fontsize=10)
    axes[1][j].axis('off')
for j in range(6, 8):
    axes[1][j].axis('off')

# 行2:Pool1特徴マップ(6チャネル、半分のサイズ)
pool1_maps = model.feature_maps['pool1'][0].cpu()
for j in range(6):
    axes[2][j].imshow(pool1_maps[j], cmap='viridis')
    axes[2][j].set_title(f'Pool1-{j}', fontsize=10)
    axes[2][j].axis('off')
for j in range(6, 8):
    axes[2][j].axis('off')

# 行3:Conv2特徴マップ(16チャネルの最初の8つ)
conv2_maps = model.feature_maps['conv2'][0].cpu()
for j in range(8):
    axes[3][j].imshow(conv2_maps[j], cmap='viridis')
    axes[3][j].set_title(f'Conv2-{j}', fontsize=10)
    axes[3][j].axis('off')

row_labels = ['Input', 'Conv1 (6ch, 24x24)', 'Pool1 (6ch, 12x12)', 'Conv2 (16ch, 8x8)']
for i, label in enumerate(row_labels):
    axes[i][7].text(1.1, 0.5, label, transform=axes[i][7].transAxes,
                    fontsize=11, verticalalignment='center', color='#333')

plt.suptitle(f'Feature Maps — Digit "{sample_label}" → Predicted "{pred}"', fontsize=15)
plt.tight_layout()
plt.show()

# 主要な観察ポイント:
# Conv1:各チャネルが異なる方向のエッジを検出。数字の輪郭が明確に見える
# Pool1:解像度は低下するが、エッジ情報は位置不変性を加えて保持される
# Conv2:特徴がより抽象的に——もはや単純なエッジではなく「エッジの組み合わせ」

7.6 異なる数字間の特徴マップ比較

# 異なる数字間のConv2特徴マップを比較
fig, axes = plt.subplots(4, 9, figsize=(22, 10))

# 4つの異なる数字のサンプルを検索
digits_to_show = [0, 1, 5, 8]
for row, digit in enumerate(digits_to_show):
    # この数字の最初のサンプルを検索
    for i in range(len(test_dataset)):
        if test_dataset[i][1] == digit:
            img, label = test_dataset[i]
            break

    inp = img.unsqueeze(0).to(device)
    with torch.no_grad():
        out = model(inp)

    # 元の画像
    axes[row][0].imshow(img.squeeze().cpu(), cmap='gray')
    axes[row][0].set_title(f'Digit {digit}', fontsize=12, fontweight='bold')
    axes[row][0].axis('off')

    # 16チャネルのConv2の最初の8つ
    fmaps = model.feature_maps['conv2'][0].cpu()
    for j in range(8):
        axes[row][j+1].imshow(fmaps[j], cmap='hot')
        if row == 0:
            axes[row][j+1].set_title(f'Ch-{j}', fontsize=10)
        axes[row][j+1].axis('off')

plt.suptitle('Conv2 Feature Maps — Different digits activate different channels', fontsize=15)
plt.tight_layout()
plt.show()

# 核心的な観察:
# "0"と"8"はどちらもリング状の構造を持つ -> 特定のチャネルが両方に高い活性化を示す
# "1"は垂直ストローク -> 垂直特徴を検出するチャネルのみが活性化される
# "5"は水平要素と曲線要素を持つ -> 混合パターンのチャネルが活性化される
# これがCNNが異なる数字を区別する「視覚的基盤」です

8. ハンズオンラボ2:TextCNNによるテキスト感情分類(Google Colab)

このラボではTextCNN[8]をゼロから構築し、映画レビューの感情分類を行います——CNNが「画像」ドメインから「テキスト」ドメインにシームレスに転用できることを実証します。

Google Colabを開く(CPUで十分)、新しいNotebookを作成し、以下のコードブロックを順番に貼り付けてください:

8.1 データ準備

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
from collections import Counter

# シンプルな映画レビューデータセット
train_texts = [
    "this movie is great and wonderful",
    "excellent film with brilliant acting",
    "amazing story beautifully told",
    "fantastic performances and stunning visuals",
    "loved every moment of this film",
    "one of the best movies i have seen",
    "a masterpiece of modern cinema",
    "incredibly moving and powerful",
    "this movie is terrible and boring",
    "worst film i have ever watched",
    "awful acting and terrible script",
    "complete waste of time and money",
    "painfully bad from start to finish",
    "disappointing and utterly forgettable",
    "horrible movie with no redeeming qualities",
    "dull uninspired and predictable",
]
train_labels = [1,1,1,1,1,1,1,1, 0,0,0,0,0,0,0,0]  # 1=ポジティブ, 0=ネガティブ

test_texts = [
    "a great and wonderful experience",
    "terrible waste of my time",
    "brilliant acting in this film",
    "the worst movie this year",
    "absolutely stunning and moving",
    "boring and utterly terrible",
]
test_labels = [1, 0, 1, 0, 1, 0]

# 語彙の構築
all_words = ' '.join(train_texts + test_texts).split()
word_counts = Counter(all_words)
vocab = {word: idx + 2 for idx, (word, _) in enumerate(word_counts.most_common())}
vocab[''] = 0
vocab[''] = 1

def text_to_ids(text, max_len=12):
    words = text.lower().split()
    ids = [vocab.get(w, 1) for w in words]
    # 固定長にパディングまたはトランケート
    if len(ids) < max_len:
        ids += [0] * (max_len - ids.__len__())
    else:
        ids = ids[:max_len]
    return ids

MAX_LEN = 12
X_train = torch.tensor([text_to_ids(t, MAX_LEN) for t in train_texts])
y_train = torch.tensor(train_labels, dtype=torch.long)
X_test = torch.tensor([text_to_ids(t, MAX_LEN) for t in test_texts])
y_test = torch.tensor(test_labels, dtype=torch.long)

print(f"Vocabulary size: {len(vocab)}")
print(f"Training samples: {len(train_texts)}")
print(f"Sequence length: {MAX_LEN}")
print(f"\nExample encoding:")
print(f"  '{train_texts[0]}'")
print(f"  → {X_train[0].tolist()}")

8.2 TextCNNモデルの構築

# TextCNN — Kim (2014) スタイル
class TextCNN(nn.Module):
    def __init__(self, vocab_size, embed_dim=32, num_filters=16, filter_sizes=[2, 3, 4], num_classes=2):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)

        # マルチスケール1D畳み込み(2-gram, 3-gram, 4-gramをそれぞれキャプチャ)
        self.convs = nn.ModuleList([
            nn.Conv1d(embed_dim, num_filters, kernel_size=fs)
            for fs in filter_sizes
        ])

        self.dropout = nn.Dropout(0.3)
        self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes)

        # 中間特徴の保存
        self.conv_outputs = {}

    def forward(self, x):
        # x: [batch, seq_len]
        embedded = self.embedding(x)  # [batch, seq_len, embed_dim]
        embedded = embedded.permute(0, 2, 1)  # [batch, embed_dim, seq_len]

        conv_outs = []
        for i, conv in enumerate(self.convs):
            c = F.relu(conv(embedded))  # [batch, num_filters, seq_len - fs + 1]
            self.conv_outputs[f'conv_{conv.kernel_size[0]}gram'] = c.detach()
            pooled = F.max_pool1d(c, c.size(2)).squeeze(2)  # [batch, num_filters]
            conv_outs.append(pooled)

        # すべての畳み込みカーネルの出力を結合
        cat = torch.cat(conv_outs, dim=1)  # [batch, num_filters * 3]
        cat = self.dropout(cat)
        logits = self.fc(cat)  # [batch, num_classes]
        return logits

model = TextCNN(vocab_size=len(vocab), embed_dim=32, num_filters=16, filter_sizes=[2, 3, 4])
total_params = sum(p.numel() for p in model.parameters())

print(f"TextCNN Architecture:")
print(f"  Embedding: {len(vocab)} × 32")
print(f"  Conv filters: 16 × [2-gram, 3-gram, 4-gram]")
print(f"  Total parameters: {total_params:,}")
print(f"\n{model}")

8.3 TextCNNのトレーニング

# TextCNNのトレーニング
optimizer = optim.Adam(model.parameters(), lr=0.005)
criterion = nn.CrossEntropyLoss()

losses = []
for epoch in range(80):
    model.train()
    optimizer.zero_grad()
    output = model(X_train)
    loss = criterion(output, y_train)
    loss.backward()
    optimizer.step()
    losses.append(loss.item())

    if (epoch + 1) % 20 == 0:
        model.eval()
        with torch.no_grad():
            train_pred = model(X_train).argmax(dim=1)
            test_pred = model(X_test).argmax(dim=1)
            train_acc = (train_pred == y_train).float().mean().item()
            test_acc = (test_pred == y_test).float().mean().item()
        print(f"Epoch {epoch+1:3d}: loss={loss.item():.4f}, train_acc={train_acc:.0%}, test_acc={test_acc:.0%}")

plt.figure(figsize=(8, 3))
plt.plot(losses, color='#0077b6')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('TextCNN Training Loss')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

8.4 N-gram畳み込み活性化パターンの可視化

# TextCNNのn-gram活性化パターンを各文について可視化
model.eval()

def visualize_text_cnn(text, label_name):
    ids = torch.tensor([text_to_ids(text, MAX_LEN)])
    with torch.no_grad():
        logits = model(ids)
        pred = logits.argmax(dim=1).item()
        probs = F.softmax(logits, dim=1)[0]

    words = text.split()[:MAX_LEN]
    sentiment = "Positive" if pred == 1 else "Negative"

    fig, axes = plt.subplots(1, 3, figsize=(18, 2.5))

    for idx, (name, conv_out) in enumerate(model.conv_outputs.items()):
        activations = conv_out[0].mean(dim=0).numpy()  # 全フィルタの平均
        n = len(activations)
        gram_size = int(name.split('_')[1][0])

        # n-gramラベルの構築
        ngram_labels = []
        for i in range(n):
            if i + gram_size <= len(words):
                ngram_labels.append(' '.join(words[i:i+gram_size]))
            else:
                ngram_labels.append(f'pad-{i}')

        colors = ['#b8922e' if v > 0 else '#0077b6' for v in activations[:len(ngram_labels)]]
        axes[idx].barh(range(len(ngram_labels)), activations[:len(ngram_labels)], color=colors)
        axes[idx].set_yticks(range(len(ngram_labels)))
        axes[idx].set_yticklabels(ngram_labels, fontsize=9)
        axes[idx].set_title(f'{gram_size}-gram activation', fontsize=11)
        axes[idx].invert_yaxis()

    plt.suptitle(f'"{text}" → {sentiment} (pos={probs[1]:.0%})', fontsize=13)
    plt.tight_layout()
    plt.show()

# テストサンプルをいくつか分析
visualize_text_cnn("a great and wonderful experience", "Positive")
visualize_text_cnn("terrible waste of my time", "Negative")
visualize_text_cnn("brilliant acting in this film", "Positive")

# 主要な観察ポイント:
# - ポジティブなレビューでは「great and wonderful」「brilliant acting」などのn-gramが最も高い活性化を示す
# - ネガティブなレビューでは「terrible waste」「waste of my」などのn-gramが最も高い活性化を示す
# - 異なるカーネルサイズが異なる長さのキーフレーズをキャプチャする
# - これがテキスト分類におけるCNNの「特徴検出」メカニズムです

8.5 カスタムテキストでのテスト

# カスタムテキストでテスト
custom_texts = [
    "this is a great movie",
    "terrible and boring film",
    "wonderful story with great acting",
    "waste of time terrible movie",
]

model.eval()
print("Custom Text Predictions:")
print("=" * 50)
for text in custom_texts:
    ids = torch.tensor([text_to_ids(text, MAX_LEN)])
    with torch.no_grad():
        logits = model(ids)
        probs = F.softmax(logits, dim=1)[0]
        pred = "Positive" if logits.argmax(dim=1).item() == 1 else "Negative"
    print(f"  [{pred:>8}] (pos={probs[1]:.0%}) {text}")

# TextCNN vs Image CNN 比較
print("\n" + "=" * 50)
print("TextCNN vs Image CNN Architecture Comparison:")
print(f"  {'Image CNN':>12} | {'TextCNN':>12}")
print(f"  {'2D Conv':>12} | {'1D Conv':>12}")
print(f"  {'Pixel Nbrhd':>12} | {'Word Window':>12}")
print(f"  {'Edge/Texture':>12} | {'n-gram':>12}")
print(f"  {'Spatial Pool':>12} | {'Global MaxPool':>12}")
print(f"  {'Cls/Detect':>12} | {'Sentiment/Topic':>12}")

9. CNNから現代アーキテクチャへ:Vision Transformerの挑戦

2021年、Dosovitskiyら[11]Vision Transformer(ViT)で視覚領域におけるCNNの支配に直接挑戦しました。ViTは画像を16x16のパッチに分割し、各パッチをトークンとして扱い、標準的なTransformerに入力します——畳み込み演算を一切使用しません。

大規模事前学習データにおいて、ViTは同等パラメータ数のCNNに匹敵するか、それを上回る性能を示しました。これは根本的な問いを提起しました:CNNの帰納的バイアス(局所性、平行移動等変性)はアドバンテージなのか、それとも制約なのか?

Liuら[15]は2022年に深い回答を提供しました:ConvNeXtはTransformerの設計原則を用いてCNNを現代化しました——より大きな畳み込みカーネル(7x7)、Batch NormalizationをLayer Normalizationに置換、活性化関数の削減——その結果、純粋なCNNアーキテクチャが再びViTの性能に匹敵しました。

これは次のことを教えてくれます:CNNが時代遅れなのではなく、レガシーCNNの特定の設計慣習が時代遅れになったのです。CNNの核心的強み——局所性と重み共有——は依然として有効です。更新が必要なのは、具体的なエンジニアリング上の選択です。

実践では、CNNとTransformerの選択はユースケースに依存します:

10. 結論

HubelとWieselの1962年の猫の視覚野実験から、LeCunの1998年のLeNet-5、2012年のAlexNetによるディープラーニング革命の幕開け、そして今日のCNNとTransformerの収束まで——畳み込みニューラルネットワークの物語は、人工知能における最もエレガントなナラティブの一つです。

CNNが私たちに教えてくれる核心原理——局所特徴検出、階層的表現学習、帰納的バイアスの力——はTransformerの台頭によって時代遅れになることはありません。むしろこれらの原理は、新しい形でより現代的なアーキテクチャに統合されています。CNNを理解することは、ディープラーニングの最も根本的な「第一原理」を理解することです。

あなたのチームがコンピュータビジョンやテキスト分類の技術ソリューションを評価しているか、CNN、ViT、ハイブリッドアーキテクチャの間で選択する必要がある場合、深い技術的な対話を歓迎します。Meta Intelligenceの研究チームが、あなたの具体的なデプロイメント環境、データ規模、レイテンシ要件に基づいて最適なアーキテクチャを特定するお手伝いをいたします。