Appearance
章节6:模块综合实战
学习目标
- 综合运用 CNN 与 Transformer 解决实际深度学习任务
- 掌握 MNIST 手写数字识别 99%+ 精度的完整实现
- 掌握 Transformer 文本情感分类的完整实现
- 理解从数据处理到模型部署的全流程
实战一:CNN 手写数字识别(MNIST 99%+ 精度)
6.1.1 任务描述
MNIST(Modified National Institute of Standards and Technology)包含 60,000 张 28×28 的手写数字灰度图(0-9),是深度学习领域的"Hello World"。
6.1.2 完整实现代码
python
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import time
import matplotlib.pyplot as plt
# ========== 1. 超参数 ==========
BATCH_SIZE = 128
EPOCHS = 20
LEARNING_RATE = 0.001
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {DEVICE}")
# ========== 2. 数据准备 ==========
# 数据增强:轻微旋转+平移,泛化能力更强
train_transform = transforms.Compose([
transforms.RandomRotation(10),
transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
test_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(
root='./data', train=True, download=True, transform=train_transform
)
test_dataset = datasets.MNIST(
root='./data', train=False, download=True, transform=test_transform
)
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE, shuffle=False)
print(f"训练集: {len(train_dataset)} 张 | 测试集: {len(test_dataset)} 张")
# ========== 3. 定义 CNN 模型 ==========
class MNIST_CNN(nn.Module):
"""
高精度 MNIST CNN 架构:
Conv → BN → ReLU → Conv → BN → ReLU → Pool → Dropout
→ Conv → BN → ReLU → Conv → BN → ReLU → Pool → Dropout
→ FC → Dropout → FC
"""
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
# Block 1: 28×28×1 → 28×28×32
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.Conv2d(32, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # 28→14
nn.Dropout2d(0.25),
# Block 2: 14×14×32 → 14×14×64
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.Conv2d(64, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # 14→7
nn.Dropout2d(0.25),
# Block 3: 7×7×64 → 7×7×128
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.Conv2d(128, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1, 1)), # 全局平均池化 → 1×1
)
self.classifier = nn.Sequential(
nn.Dropout(0.5),
nn.Linear(128, 10)
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
x = self.classifier(x)
return x
def count_parameters(model):
"""统计模型参数量"""
return sum(p.numel() for p in model.parameters() if p.requires_grad)
model = MNIST_CNN().to(DEVICE)
print(f"模型参数量: {count_parameters(model):,}")
print(model)
# ========== 4. 训练工具函数 ==========
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', factor=0.5, patience=3, verbose=True
)
def train_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss = 0.0
correct = 0
total = 0
for X, y in loader:
X, y = X.to(device), y.to(device)
optimizer.zero_grad()
outputs = model(X)
loss = criterion(outputs, y)
loss.backward()
optimizer.step()
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += y.size(0)
correct += (predicted == y).sum().item()
return total_loss / len(loader), 100 * correct / total
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for X, y in loader:
X, y = X.to(device), y.to(device)
outputs = model(X)
loss = criterion(outputs, y)
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += y.size(0)
correct += (predicted == y).sum().item()
return total_loss / len(loader), 100 * correct / total
# ========== 5. 训练循环 ==========
print("\n开始训练...")
print("=" * 60)
best_acc = 0.0
history = {'train_loss': [], 'train_acc': [], 'test_acc': []}
for epoch in range(1, EPOCHS + 1):
start_time = time.time()
train_loss, train_acc = train_epoch(model, train_loader, optimizer, criterion, DEVICE)
test_loss, test_acc = evaluate(model, test_loader, criterion, DEVICE)
scheduler.step(test_loss) # 学习率调整
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['test_acc'].append(test_acc)
# 保存最佳模型
if test_acc > best_acc:
best_acc = test_acc
torch.save(model.state_dict(), 'mnist_best.pth')
epoch_time = time.time() - start_time
print(f"Epoch {epoch:2d}/{EPOCHS} | "
f"Train Loss: {train_loss:.4f} | "
f"Train Acc: {train_acc:.2f}% | "
f"Test Acc: {test_acc:.2f}% | "
f"Best: {best_acc:.2f}% | "
f"Time: {epoch_time:.1f}s")
print("=" * 60)
print(f"训练完成!最佳测试准确率: {best_acc:.2f}%")
# ========== 6. 推理与可视化 ==========
def predict_image(model, image_tensor, device):
"""对单张图片推理"""
model.eval()
with torch.no_grad():
image_tensor = image_tensor.unsqueeze(0).to(device) # 增加 batch 维度
output = model(image_tensor)
probabilities = F.softmax(output, dim=1)
pred_class = torch.argmax(probabilities, dim=1).item()
confidence = probabilities[0, pred_class].item()
return pred_class, confidence
# 从测试集中取一些样本展示
model.load_state_dict(torch.load('mnist_best.pth'))
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i, ax in enumerate(axes.flat):
# 取测试集中第 i 个样本
img, true_label = test_dataset[i]
pred_label, confidence = predict_image(model, img, DEVICE)
color = 'green' if pred_label == true_label else 'red'
ax.imshow(img.squeeze(), cmap='gray')
ax.set_title(f"真实: {true_label} | 预测: {pred_label}\n置信度: {confidence:.2%}",
color=color, fontsize=10)
ax.axis('off')
plt.tight_layout()
plt.show()
# ========== 7. 错误分析 ==========
print("\n错误分析:")
model.eval()
errors = []
with torch.no_grad():
for X, y in test_loader:
X, y = X.to(DEVICE), y.to(DEVICE)
outputs = model(X)
_, predicted = torch.max(outputs, 1)
mask = (predicted != y)
errors.extend([
(img.cpu(), true.cpu(), pred.cpu())
for img, true, pred in zip(X[mask], y[mask], predicted[mask])
])
print(f"测试集共 {len(test_dataset)} 张,错误 {len(errors)} 张,准确率 {100 - 100*len(errors)/len(test_dataset):.2f}%")
# 展示前 10 个错误样本
if errors:
fig, axes = plt.subplots(2, 5, figsize=(12, 5))
for i, ax in enumerate(axes.flat):
if i >= len(errors):
break
img, true_label, pred_label = errors[i]
ax.imshow(img.squeeze(), cmap='gray')
ax.set_title(f"真实:{true_label.item()}→预测:{pred_label.item()}", color='red')
ax.axis('off')
plt.suptitle("分类错误样本", fontsize=14)
plt.tight_layout()
plt.show()6.1.3 关键优化点
| 优化技巧 | 说明 | 效果 |
|---|---|---|
| 数据增强 | RandomRotation + RandomAffine | 提升泛化,约 +0.3% |
| Batch Normalization | 每层 Conv 后接 BN | 加速收敛,稳定训练 |
| Dropout | Conv2d(p=0.25) + FC(p=0.5) | 防止过拟合 |
| 全局平均池化 | 替代 Flatten + 大 FC 层 | 大幅减少参数量 |
| ReduceLROnPlateau | 验证 loss 不降时自动降低学习率 | 精细调优 |
实战二:Transformer 文本情感分类
6.2.1 任务描述
使用 Transformer Encoder 对 IMDB 电影评论进行二分类(正面/负面)情感分析。
6.2.2 完整实现代码
python
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Dataset
import numpy as np
import re
import urllib.request
import zipfile
import os
from collections import Counter
# ========== 1. 数据准备:IMDB 子集 ==========
def download_imdb():
"""下载 IMDB 数据集(如果不存在)"""
url = "https://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz"
# 实际使用时:torchtext 或 HuggingFace datasets 更方便
# 这里使用本地文件或小样本演示
pass
# 简易 IMDB 数据加载(用于演示)
sample_reviews = [
("This movie was fantastic! I loved every minute of it.", 1),
("Terrible film, waste of time and money.", 0),
("Amazing acting and beautiful cinematography.", 1),
("Boring and predictable plot, very disappointed.", 0),
("One of the best movies I have ever seen!", 1),
("Awful script and poor direction.", 0),
("A masterpiece of modern cinema, highly recommended.", 1),
("Not worth watching, terrible performances.", 0),
("Brilliant storytelling and great character development.", 1),
("I fell asleep halfway through, extremely dull.", 0),
]
# 实际使用时应加载完整 IMDB 数据集:
# from torchtext.datasets import IMDB
# from torchtext.data.utils import get_tokenizer
# train_iter, test_iter = IMDB(split=('train', 'test'))
# ========== 2. 文本预处理与词表构建 ==========
def tokenize(text):
"""简易分词"""
text = text.lower()
text = re.sub(r'[^\w\s]', '', text)
return text.split()
# 构建词汇表(使用完整数据集时,从所有训练数据构建)
all_words = []
for text, _ in sample_reviews:
all_words.extend(tokenize(text))
word_counts = Counter(all_words)
vocab = {word: idx + 2 for idx, (word, _) in enumerate(word_counts.most_common())}
vocab['<PAD>'] = 0
vocab['<UNK>'] = 1
vocab_size = len(vocab)
print(f"词汇表大小: {vocab_size}")
# ========== 3. 自定义 Dataset ==========
class SentimentDataset(Dataset):
def __init__(self, reviews, labels, vocab, max_len=50):
self.data = []
for text, label in zip(reviews, labels):
tokens = tokenize(text)
ids = [vocab.get(token, vocab['<UNK>']) for token in tokens[:max_len]]
# 填充到固定长度
if len(ids) < max_len:
ids = ids + [vocab['<PAD>']] * (max_len - len(ids))
self.data.append((torch.tensor(ids, dtype=torch.long),
torch.tensor(label, dtype=torch.long)))
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
# 拆分训练/验证集
reviews = [r for r, _ in sample_reviews]
labels = [l for _, l in sample_reviews]
split = int(0.8 * len(reviews))
train_dataset = SentimentDataset(reviews[:split], labels[:split], vocab, max_len=50)
val_dataset = SentimentDataset(reviews[split:], labels[split:], vocab, max_len=50)
train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=4)
# ========== 4. Transformer 情感分类模型 ==========
class TransformerSentiment(nn.Module):
"""
Transformer Encoder 用于文本分类:
Embedding → PositionalEncoding → TransformerEncoder → Pooling → FC
"""
def __init__(self, vocab_size, d_model=128, num_heads=4,
num_layers=3, d_ff=256, max_len=50, num_classes=2, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_encoding = self._generate_positional_encoding(max_len, d_model)
self.dropout = nn.Dropout(dropout)
# Transformer Encoder 层
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=num_heads,
dim_feedforward=d_ff,
dropout=dropout,
batch_first=True,
activation='relu'
)
self.transformer_encoder = nn.TransformerEncoder(
encoder_layer, num_layers=num_layers
)
# 分类头
self.classifier = nn.Sequential(
nn.Linear(d_model, d_model // 2),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(d_model // 2, num_classes)
)
def _generate_positional_encoding(self, max_len, d_model):
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2, dtype=torch.float32) *
-(torch.log(torch.tensor(10000.0)) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
return pe.unsqueeze(0) # [1, max_len, d_model]
def forward(self, x):
# x: [batch, seq_len]
seq_len = x.size(1)
x = self.embedding(x) # [batch, seq_len, d_model]
x = x + self.pos_encoding[:, :seq_len, :].to(x.device)
x = self.dropout(x)
# Transformer Encoder
x = self.transformer_encoder(x) # [batch, seq_len, d_model]
# 池化:取所有 token 的均值作为序列表示
x = x.mean(dim=1) # [batch, d_model]
# 分类
return self.classifier(x) # [batch, num_classes]
# ========== 5. 训练 ==========
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"使用设备: {DEVICE}")
model = TransformerSentiment(
vocab_size=vocab_size,
d_model=128,
num_heads=4,
num_layers=3,
d_ff=256,
max_len=50,
num_classes=2
).to(DEVICE)
criterion = nn.CrossEntropyLoss()
optimizer = optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
print(f"模型参数量: {sum(p.numel() for p in model.parameters()):,}")
def train_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss = 0.0
correct = 0
total = 0
for texts, labels in loader:
texts, labels = texts.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(texts)
loss = criterion(outputs, labels)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 梯度裁剪
optimizer.step()
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
return total_loss / len(loader), 100 * correct / total
def evaluate(model, loader, criterion, device):
model.eval()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for texts, labels in loader:
texts, labels = texts.to(device), labels.to(device)
outputs = model(texts)
loss = criterion(outputs, labels)
total_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
return total_loss / len(loader), 100 * correct / total
# 训练循环
EPOCHS = 30
for epoch in range(1, EPOCHS + 1):
train_loss, train_acc = train_epoch(model, train_loader, optimizer, criterion, DEVICE)
val_loss, val_acc = evaluate(model, val_loader, criterion, DEVICE)
if epoch % 5 == 0 or epoch == 1:
print(f"Epoch {epoch:2d}/{EPOCHS} | "
f"Train Loss: {train_loss:.4f} Acc: {train_acc:.2f}% | "
f"Val Loss: {val_loss:.4f} Acc: {val_acc:.2f}%")
print("训练完成!")
# ========== 6. 推理演示 ==========
def predict_sentiment(model, text, vocab, max_len=50, device=DEVICE):
model.eval()
tokens = tokenize(text)
ids = [vocab.get(token, vocab['<UNK>']) for token in tokens[:max_len]]
if len(ids) < max_len:
ids = ids + [vocab['<PAD>']] * (max_len - len(ids))
input_tensor = torch.tensor([ids], dtype=torch.long).to(device)
with torch.no_grad():
output = model(input_tensor)
prob = torch.softmax(output, dim=1)
pred = torch.argmax(prob, dim=1).item()
confidence = prob[0, pred].item()
sentiment = "正面 😊" if pred == 1 else "负面 😞"
return sentiment, confidence
test_texts = [
"I absolutely loved this movie, the acting was superb!",
"What a waste of time, truly terrible and boring.",
"This film was okay, not great but not terrible either.",
"A wonderful experience from start to finish, highly recommended!",
]
print("\n情感分类演示:")
print("=" * 50)
for text in test_texts:
sentiment, confidence = predict_sentiment(model, text, vocab)
print(f"文本: {text}")
print(f"情感: {sentiment} (置信度: {confidence:.2%})\n")6.2.3 在完整 IMDB 上训练的建议
将上面示例中的 sample_reviews 替换为以下方式加载完整 IMDB 数据集:
python
# 方式一:使用 torchtext(推荐)
# pip install torchtext
from torchtext.datasets import IMDB
from torchtext.data.utils import get_tokenizer
from torchtext.vocab import build_vocab_from_iterator
# tokenizer = get_tokenizer('basic_english')
# train_iter, test_iter = IMDS(split=('train', 'test'))
#
# def yield_tokens(data_iter):
# for _, text in data_iter:
# yield tokenizer(text)
#
# # 构建词表
# vocab = build_vocab_from_iterator(
# yield_tokens(train_iter), specials=['<PAD>', '<UNK>'],
# max_tokens=20000
# )
# vocab.set_default_index(vocab['<UNK>'])
# 方式二:使用 HuggingFace datasets
# pip install datasets
# from datasets import load_dataset
# dataset = load_dataset("imdb")两个实战项目对比
| 对比维度 | CNN + MNIST | Transformer + IMDB |
|---|---|---|
| 数据类型 | 图像(灰度 28×28) | 文本(变长序列) |
| 核心架构 | Conv2d + BN + Pooling | Transformer Encoder |
| 参数量 | ~150K | ~500K+ |
| 预期精度 | 99.5%+ | 85-90% |
| 训练时间(CPU) | ~5 分钟 | ~30 分钟 |
| 关键技巧 | 数据增强、BN、全局池化 | 词表构建、位置编码、梯度裁剪 |
综合练习
基础练习
- MNIST 模型压缩:尝试将 MNIST CNN 参数量减少到 50K 以下仍保持 99%+ 准确率。
- 数据增强对比:在 MNIST 训练中分别使用和不使用数据增强,对比测试集精度。
- 超参数扫描:对 Transformer 情感分类的
num_heads、num_layers、d_model进行网格搜索。
进阶练习
- 跨领域应用:将 Transformer 模型改为多分类(如 AG_NEWS 新闻分类,4类)。
- 模型集成:训练 3 个不同初始化的 CNN 模型,用投票集成提升 MNIST 精度。
- 端到端部署:将训练好的 MNIST 模型导出为 ONNX,编写一个简单的 Flask Web API 进行推理。
挑战练习
- 可视化热力图:使用 Grad-CAM 可视化 CNN 关注的图像区域和 Transformer 关注的文本区域。
- 从零实现:不使用 nn.Conv2d 和 nn.TransformerEncoder,仅用 nn.Linear 手写实现卷积操作和注意力机制,完成上述两个任务。