一、從生物視覺到人工視覺:CNN 的起源
1962 年,神經科學家 David Hubel 和 Torsten Wiesel 在一隻麻醉的貓身上做了一個改變歷史的實驗[1]。他們發現貓的視覺皮層中存在兩種細胞:簡單細胞(對特定方向的邊緣敏感)和複雜細胞(對位置有一定的不變性)。更重要的是,視覺資訊是分層處理的——低層偵測邊緣,中層組合成形狀,高層辨識物體。
這個發現直接啟發了卷積神經網路的三個核心設計原則:
- 局部感受野(Local Receptive Fields):每個神經元只看輸入的一小塊區域,對應 CNN 的卷積核
- 權重共享(Weight Sharing):同一個特徵偵測器在整個圖像上滑動,對應 CNN 的卷積操作
- 空間下取樣(Spatial Subsampling):逐層降低解析度,對應 CNN 的池化操作
1998 年,Yann LeCun 等人[2]在 LeNet-5 中將這些原則工程化——一個僅有 5 層的卷積網路就能以 99.2% 的準確率辨識手寫數字,被美國郵局用於處理全國 10% 的手寫支票。LeNet-5 證明了一個深刻的觀點:好的歸納偏置(inductive bias)比更多的參數更重要。
然而,CNN 真正的爆發要等到 2012 年。Krizhevsky、Sutskever 和 Hinton 的 AlexNet[3]在 ImageNet 大規模視覺辨識挑戰賽上以壓倒性優勢獲勝——Top-5 錯誤率 16.4%,而第二名的傳統方法是 26.2%。這個 10 個百分點的差距震驚了整個計算機視覺社群,正式開啟了深度學習時代[16]。
二、核心機制:卷積、池化、特徵層次
2.1 卷積操作:局部特徵偵測
卷積的本質是一個滑動窗口的模式匹配。一個 3×3 的卷積核(kernel/filter)在輸入圖像上滑動,每個位置計算核與對應區域的點積,產生一個輸出值。整張圖像滑動完畢,就得到一張特徵圖(feature map)。
一個卷積層通常包含多個卷積核——每個核學習偵測一種不同的模式。例如,第一層的 6 個卷積核可能分別學會偵測水平邊緣、垂直邊緣、45° 斜線、135° 斜線、角點和曲線。
為什麼卷積比全連接更有效?Goodfellow 等人的經典教科書[10]指出三個關鍵優勢:
- 稀疏交互:每個輸出只依賴局部輸入,參數量從 O(n²) 降到 O(k²)(k 是核大小)
- 權重共享:同一個核在所有位置重複使用,無論圖像多大,核的參數量不變
- 平移等變性:貓在圖像左邊和右邊,同一個核都能偵測到——CNN 天然具備平移不變性
2.2 池化操作:空間壓縮與不變性
池化(Pooling)在特徵圖上做空間下取樣——最常見的 2×2 最大池化(Max Pooling)將每 4 個像素取最大值,把特徵圖的長寬各縮小一半。
池化的意義不只是壓縮資料——它帶來了局部平移不變性。某個特徵在 2×2 區域內無論在哪個精確位置被偵測到,池化後的輸出都一樣。這讓 CNN 對微小的位移和形變具有魯棒性。
2.3 層次化特徵:從邊緣到物體
Zeiler 與 Fergus[9]的經典視覺化研究揭示了 CNN 各層學到的特徵呈清晰的層次結構:
- 第 1 層:邊緣和色彩梯度——最基本的視覺元素
- 第 2 層:角點、紋理和簡單形狀——邊緣的組合
- 第 3 層:物體部件——眼睛、輪子、窗戶
- 第 4-5 層:完整物體和場景——臉、汽車、建築
這個層次結構與 Hubel 和 Wiesel 發現的視覺皮層分層處理機制驚人地吻合——CNN 確實在某種程度上「重新發明」了生物視覺的資訊處理策略。
三、架構演進:從 LeNet 到 ConvNeXt
| 年份 | 架構 | 深度 | 核心創新 | ImageNet Top-5 Error |
|---|---|---|---|---|
| 1998 | LeNet-5[2] | 5 | 卷積 + 池化的基本範式 | —(MNIST 0.8%) |
| 2012 | AlexNet[3] | 8 | ReLU、Dropout、GPU 訓練 | 16.4% |
| 2014 | VGGNet[4] | 16-19 | 統一用 3x3 小卷積核堆深 | 7.3% |
| 2014 | GoogLeNet[5] | 22 | Inception 多尺度並行卷積 | 6.7% |
| 2015 | ResNet[6] | 152 | 殘差連接(skip connections) | 3.6% |
| 2017 | MobileNet[12] | 28 | 深度可分離卷積 | —(高效部署) |
| 2019 | EfficientNet[7] | 自動縮放 | 複合縮放(深度×寬度×解析度) | 2.9%(B7) |
| 2022 | ConvNeXt[15] | 可變 | 用 Transformer 設計理念現代化 CNN | 與 ViT 持平 |
幾個關鍵轉折點值得深入理解:
VGGNet 的洞見[4]:用兩個 3×3 卷積核串聯等效一個 5×5 核(感受野相同),但參數量更少(18 vs 25),且中間多了一次非線性啟動。結論:更深更窄優於更淺更寬。
ResNet 的突破[6]:隨著網路加深到 20 層以上,訓練錯誤率反而上升——不是過擬合,而是退化問題。ResNet 的殘差連接(F(x) + x)解決了這個問題:讓網路學習「殘差」而非直接映射,使得梯度能順暢地流過 152 層。這是深度學習最重要的架構創新之一。
1×1 卷積[14]:看似矛盾——1×1 的卷積核沒有空間感受野。但它在通道維度上做線性組合,可以降維或升維。GoogLeNet 的 Inception 模組和 ResNet 的瓶頸結構都依賴 1×1 卷積來控制計算量。
Batch Normalization[13]:在每一層的輸出後做正規化,穩定訓練過程,允許使用更大的學習率。BN 如此有效,以至於幾乎所有現代 CNN 都將它作為標準組件。
四、CNN 在圖像 AI 的核心應用
CNN 在計算機視覺中的應用已經滲透到每個子領域:
- 圖像分類:從 ImageNet 的 1000 類分類到醫療影像的病灶辨識,CNN 是最成熟的解決方案
- 物體偵測:R-CNN 系列、YOLO、SSD 在 CNN 骨幹上加入區域提議和邊界框回歸
- 語義分割:FCN(全卷積網路)、U-Net 將分類從圖像級擴展到像素級
- 圖像生成:GAN 的判別器和生成器、擴散模型的 U-Net 骨幹,底層都是 CNN
- 視覺特徵提取:預訓練 CNN(ResNet、EfficientNet)的中間層特徵被廣泛用於遷移學習
五、CNN 在文字 AI 的應用:TextCNN
2014 年,Yoon Kim[8]發表了一篇簡潔有力的論文,證明卷積神經網路不只能看圖,也能讀文字。TextCNN 的設計極其優雅:
- 輸入表示:將句子中的每個詞轉為 word embedding 向量(如 300 維),整個句子成為一個 n×300 的矩陣——把文字「看成」一張圖片
- 1D 卷積:用不同大小的卷積核(如 3、4、5 個詞的視窗)在句子上滑動,捕捉不同長度的 n-gram 模式
- 全域最大池化:每個卷積核的輸出取最大值——無論句子多長,都壓縮為固定長度的向量
- 全連接分類:拼接所有核的輸出,通過 FC 層 + Softmax 做分類
TextCNN 的直覺:不同大小的卷積核就像在文字中搜尋不同長度的「關鍵片語」。3-gram 核可能捕捉「not very good」,4-gram 核可能捕捉「waste of my time」——這些局部模式組合起來就足以判斷情感傾向。
儘管 Transformer 和 BERT 在許多 NLP 任務上已經超越 CNN,TextCNN 在工業界仍有不可替代的位置:推論速度快 10-100 倍(無自注意力的二次方複雜度)、部署資源需求低、在短文字分類上精度接近 BERT。
六、互動式 3D 視覺化:CNN 如何辨識手寫數字
理解 CNN 的最佳方式是「看見」它。我們用 Three.js 建構了一個互動式 3D 視覺化,展示一個 LeNet 風格的 CNN 如何處理 MNIST 手寫數字:
在這個視覺化中你可以:
- 選擇不同數字(0-9)觀察輸入如何改變
- 點擊不同層(Conv1 → Pool1 → Conv2 → Pool2 → FC → Output)查看各層的資訊
- 拖曳旋轉從不同角度觀察 3D 網路結構
- 啟動資料流動畫觀察訊號如何從輸入流向輸出
觀察重點:
- 特徵圖的層次遞進:輸入層是清晰的 28×28 像素,Conv1 產生 6 張模糊的特徵圖(邊緣偵測),Conv2 產生 16 張更小更抽象的特徵圖(模式組合)
- 空間維度的壓縮:每次池化後特徵圖縮小一半(28→14→7→...),但通道數增加(1→6→16)——用更多「種類」的特徵補償空間資訊的損失
- 從空間到語義的轉變:卷積層保留空間結構,全連接層打破空間結構——FC 層不再關心特徵「在哪裡」,只關心特徵「是什麼」
七、Hands-on Lab 1:MNIST 手寫辨識 × 特徵圖視覺化(Google Colab)
這個 Lab 從零建構一個 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 channels, 5x5 kernel
self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=0)
# Conv2: 6 -> 16 channels, 5x5 kernel
self.conv2 = nn.Conv2d(6, 16, kernel_size=5, padding=0)
# FC layers
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 個 epoch ★
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) — 紅=正權重, 藍=負權重', 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))
# Row 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')
# Row 1: Conv1 特徵圖 (6 channels)
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')
# Row 2: Pool1 特徵圖 (6 channels, half size)
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')
# Row 3: Conv2 特徵圖 (取前 8 of 16 channels)
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')
# 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 — 不同數字激活不同的通道', fontsize=15)
plt.tight_layout()
plt.show()
# ★ 核心觀察 ★
# "0" 和 "8" 都有圓環結構 → 某些通道對兩者都有高激活
# "1" 是垂直線條 → 只有偵測垂直特徵的通道被激活
# "5" 有水平和曲線 → 混合模式的通道被激活
# 這就是 CNN 區分不同數字的「視覺依據」
八、Hands-on Lab 2:TextCNN × 文字情感分類(Google Colab)
這個 Lab 從零建構 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=positive, 0=negative
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]
# Padding or truncating to fixed length
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() # 平均跨所有 filter
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" 的激活最高
# - 不同大小的卷積核捕捉到不同長度的關鍵片語
# - 這就是 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 架構對照:")
print(f" {'Image CNN':>12} | {'TextCNN':>12}")
print(f" {'2D 卷積':>12} | {'1D 卷積':>12}")
print(f" {'像素鄰域':>12} | {'詞窗口':>12}")
print(f" {'邊緣/紋理':>12} | {'n-gram':>12}")
print(f" {'空間池化':>12} | {'全域最大池化':>12}")
print(f" {'分類/偵測':>12} | {'情感/主題':>12}")
九、從 CNN 到現代架構:Vision Transformer 的挑戰
2021 年,Dosovitskiy 等人[11]的 Vision Transformer(ViT)向 CNN 在視覺領域的統治地位發起了正面挑戰。ViT 將圖片分割為 16×16 的 patch,每個 patch 作為一個 token 送入標準 Transformer——完全不使用卷積操作。
在大規模預訓練數據上,ViT 的性能追平甚至超越了同等參數量的 CNN。這引發了一個基本問題:CNN 的歸納偏置(局部性、平移等變性)到底是優勢還是限制?
Liu 等人[15]在 2022 年給出了一個深刻的回答:ConvNeXt 將 CNN 用 Transformer 的設計理念重新現代化——更大的卷積核(7×7)、Layer Normalization 取代 Batch Normalization、更少的啟動函數——結果是純 CNN 架構重新追平 ViT 的性能。
這告訴我們:不是 CNN 不行了,而是舊式 CNN 的某些設計慣例過時了。CNN 的核心優勢——局部性和權重共享——仍然有效;需要更新的是具體的工程選擇。
實務上,CNN 與 Transformer 的選擇取決於場景:
- 邊緣設備 / 即時推論:CNN(特別是 MobileNet、EfficientNet)仍是最佳選擇——計算效率和模型大小的優勢明顯
- 大規模預訓練 / 需要全域上下文:ViT 和 Transformer 變體佔優——自注意力的全域感受野在大數據上的學習能力更強
- 混合架構:越來越多的模型在 CNN 骨幹上加入注意力機制(如 EfficientNetV2),或在 Transformer 中加入卷積(如 CvT),兩者的邊界正在模糊化
十、結語
從 1962 年 Hubel 和 Wiesel 的貓視覺皮層實驗,到 1998 年 LeCun 的 LeNet-5,到 2012 年 AlexNet 引爆的深度學習革命,到今天 CNN 與 Transformer 的融合演進——卷積神經網路的故事是人工智慧最優雅的敘事之一。
CNN 教會我們的核心原則——局部特徵偵測、層次化表徵學習、歸納偏置的力量——不會因為 Transformer 的崛起而過時。相反,這些原則正以新的形式被融入更現代的架構中。理解 CNN,就是理解了深度學習最核心的「第一性原理」。
如果您的團隊正在評估計算機視覺或文字分類的技術方案,或需要在 CNN、ViT 和混合架構之間做出選擇,歡迎與我們進行深度技術對話。超智諮詢的研究團隊能夠協助您根據具體的部署環境、資料規模和延遲要求,找到最適合的架構方案。