第14章 框架实操:用 PyTorch 跑通一个完整深度学习项目
一句话点题: 前几章你学了神经网络、CNN、RNN、Transformer,这章我们把它们全串起来,从零到部署跑完一个真实项目。
14.1 为什么选 PyTorch
深度学习框架很多,但 2024 年的格局已经很清楚了:
| 框架 | 地位 | 特点 |
|---|---|---|
| PyTorch | 学术界 + 开源界绝对主流 | 动态图,调试友好,Pythonic |
| TensorFlow | 工业界仍有存量,新项目越来越少 | 静态图(TF2也支持动态),部署生态好 |
| JAX | 前沿研究领域兴起 | 函数式编程,自动并行,学习曲线陡 |
| PaddlePaddle | 国产框架,百度系 | 中文文档好,国内政策支持 |
| MindSpore | 华为出品 | 昇腾芯片适配 |
为什么推荐 PyTorch?
- 大模型生态全在 PyTorch 上:Hugging Face Transformers、LLaMA、Qwen、Stable Diffusion 全是 PyTorch
- 调试直观 :动态图意味着你可以用
print()看每一步的值,跟写普通 Python 一样 - 社区资源最多:论文开源代码 90% 是 PyTorch
- 学习曲线友好:API 设计贴近 Python 直觉
本章所有代码基于 PyTorch 2.x,Python 3.10+。
14.2 项目背景:中文情感分析
我们要做一个中文餐厅评论情感分析系统------输入一条评论,判断是好评还是差评。
为什么选这个项目
- 够典型:文本分类是 NLP 的"Hello World"
- 能对比三种架构:同一个任务用 CNN、LSTM、Transformer 分别实现,直观感受差异
- 能跑起来:数据集小,普通笔记本 CPU 就能跑
- 有实际价值:情感分析是电商、餐饮、舆情监控的基础能力
项目目标
- 输入:"这家火锅味道不错,服务也很好,下次还来!" → 输出:好评(置信度 0.95)
- 输入:"等了半小时才上菜,菜还是凉的,再也不来了。" → 输出:差评(置信度 0.92)
14.3 环境准备
bash
# 创建虚拟环境
python -m venv dl-env
source dl-env/bin activate # Windows: dl-env\Scripts\activate
# 安装依赖
pip install torch torchvision torchaudio
pip install jieba pandas scikit-learn matplotlib tqdm
验证安装:
python
import torch
print(f"PyTorch 版本: {torch.__version__}")
print(f"CUDA 可用: {torch.cuda.is_available()}")
# 如果有GPU: True,没有也无所谓,这个项目CPU也能跑
14.4 第一步:准备数据
生成模拟数据集
真实项目用公开数据集(如 ChnSentiCorp),这里为了让你能直接跑,生成一份模拟数据:
python
import pandas as pd
import random
import os
def generate_dataset():
"""生成中文餐厅评论情感分析数据集"""
# 好评模板
positive_templates = [
"这家{cuisine}味道{adj1},{adj2},下次还来",
"环境{adj1},服务{adj2},菜品也很好",
"性价比{adj1},分量{adj2},推荐",
"来了{count}次了,每次都{adj1}",
"{dish}特别{adj1},朋友都说{adj2}",
]
# 差评模板
negative_templates = [
"等了{count}分钟才上菜,菜还是凉的",
"服务{bad_adj},菜品也{bad_adj},失望",
"价格{bad_adj},分量{bad_adj},不值",
"环境{bad_adj},不会再来第二次",
"{dish}完全不{adj1},浪费钱",
]
cuisines = ["火锅", "烧烤", "日料", "川菜", "粤菜", "西餐", "泰餐", "韩餐"]
dishes = ["牛肉", "羊肉", "鱼", "虾", "豆腐", "沙拉", "披萨", "拉面"]
adj1 = ["不错", "好吃", "地道", "新鲜", "正宗", "入味"]
adj2 = ["很满意", "推荐", "赞", "值得", "棒"]
bad_adj = ["太差", "很差", "一般般", "难吃", "不行", "恶心"]
reviews = []
labels = []
# 生成好评
for _ in range(800):
t = random.choice(positive_templates)
review = t.format(
cuisine=random.choice(cuisines),
dish=random.choice(dishes),
adj1=random.choice(adj1),
adj2=random.choice(adj2),
count=random.randint(2, 10)
)
reviews.append(review)
labels.append(1)
# 生成差评
for _ in range(800):
t = random.choice(negative_templates)
review = t.format(
cuisine=random.choice(cuisines),
dish=random.choice(dishes),
adj1=random.choice(adj1),
adj2=random.choice(adj2),
bad_adj=random.choice(bad_adj),
count=random.randint(20, 60)
)
reviews.append(review)
labels.append(0)
# 打乱
data = list(zip(reviews, labels))
random.shuffle(data)
reviews, labels = zip(*data)
df = pd.DataFrame({"review": reviews, "label": labels})
os.makedirs("data", exist_ok=True)
df.to_csv("data/restaurant_reviews.csv", index=False, encoding="utf-8-sig")
print(f"数据集已生成: {len(df)} 条")
print(f"好评: {sum(labels)} 条, 差评: {len(labels)-sum(labels)} 条")
print(df.head())
return df
generate_dataset()
输出示例:
数据集已生成: 1600 条
好评: 800 条, 差评: 800 条
review label
0 牛肉特别好吃,朋友都说赞 1
1 等了45分钟才上菜,菜还是凉的 0
2 这家火锅味道不错,很满意,下次还来 1
数据探索
python
import pandas as pd
import matplotlib.pyplot as plt
df = pd.read_csv("data/restaurant_reviews.csv")
# 评论长度分布
df["length"] = df["review"].str.len()
print(f"评论长度统计:")
print(df["length"].describe())
# 好评差评比例
print(f"\n标签分布:\n{df['label'].value_counts()}")
# 查看一些样本
print("\n好评样本:")
for r in df[df.label==1]["review"].head(3):
print(f" {r}")
print("\n差评样本:")
for r in df[df.label==0]["review"].head(3):
print(f" {r}")
14.5 第二步:数据预处理------分词 + 词表
中文和英文不同,英文天然有空格分词,中文需要用分词工具。
python
import jieba
from collections import Counter
class VocabBuilder:
"""中文分词 + 词表构建"""
def __init__(self, max_vocab_size=5000, min_freq=2):
self.max_vocab_size = max_vocab_size
self.min_freq = min_freq
self.word2idx = {}
self.idx2word = {}
# 特殊Token
self.PAD_TOKEN = "<pad>" # 填充
self.UNK_TOKEN = "<unk>" # 未知词
self.CLS_TOKEN = "<cls>" # 句子开头(Transformer用)
def tokenize(self, text):
"""中文分词"""
return list(jieba.cut(text))
def build_vocab(self, texts):
"""构建词表"""
# 统计词频
counter = Counter()
for text in texts:
words = self.tokenize(text)
counter.update(words)
# 按频率排序,取前max_vocab_size个
most_common = counter.most_common(self.max_vocab_size)
# 特殊Token占前几个位置
self.word2idx = {
self.PAD_TOKEN: 0,
self.UNK_TOKEN: 1,
self.CLS_TOKEN: 2,
}
for word, freq in most_common:
if freq >= self.min_freq and word not in self.word2idx:
self.word2idx[word] = len(self.word2idx)
self.idx2word = {idx: word for word, idx in self.word2idx.items()}
print(f"词表大小: {len(self.word2idx)}")
return self.word2idx
def encode(self, text, max_len=None):
"""文本转ID序列"""
words = self.tokenize(text)
ids = [self.word2idx.get(word, self.word2idx[self.UNK_TOKEN]) for word in words]
# 截断或填充
if max_len:
if len(ids) < max_len:
ids = ids + [0] * (max_len - len(ids)) # PAD
else:
ids = ids[:max_len]
return ids
def decode(self, ids):
"""ID序列转文本"""
return "".join([self.idx2word.get(idx, "?") for idx in ids if idx > 2])
# 测试分词和词表
vocab = VocabBuilder(max_vocab_size=3000, min_freq=2)
vocab.build_vocab(df["review"].tolist())
# 看看分词效果
sample = "这家火锅味道不错,下次还来"
print(f"原文: {sample}")
print(f"分词: {vocab.tokenize(sample)}")
print(f"编码: {vocab.encode(sample, max_len=20)}")
输出示例:
词表大小: 287
原文: 这家火锅味道不错,下次还来
分词: ['这', '家', '火锅', '味道', '不错', ',', '下次', '还', '来']
编码: [12, 8, 5, 23, 3, 4, 15, 9, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
14.6 第三步:构建 DataLoader
python
import torch
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split
MAX_LEN = 30 # 评论最大长度
class ReviewDataset(Dataset):
"""餐厅评论数据集"""
def __init__(self, texts, labels, vocab, max_len=MAX_LEN):
self.texts = texts
self.labels = labels
self.vocab = vocab
self.max_len = max_len
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = str(self.texts[idx])
label = int(self.labels[idx])
ids = self.vocab.encode(text, max_len=self.max_len)
return {
"input_ids": torch.tensor(ids, dtype=torch.long),
"label": torch.tensor(label, dtype=torch.long)
}
# 划分训练集/验证集/测试集
train_texts, temp_texts, train_labels, temp_labels = train_test_split(
df["review"].values, df["label"].values, test_size=0.3, random_state=42, stratify=df["label"]
)
val_texts, test_texts, val_labels, test_labels = train_test_split(
temp_texts, temp_labels, test_size=0.5, random_state=42, stratify=temp_labels
)
print(f"训练集: {len(train_texts)}")
print(f"验证集: {len(val_texts)}")
print(f"测试集: {len(test_texts)}")
# 创建DataLoader
BATCH_SIZE = 32
train_dataset = ReviewDataset(train_texts, train_labels, vocab)
val_dataset = ReviewDataset(val_texts, val_labels, vocab)
test_dataset = ReviewDataset(test_texts, test_labels, vocab)
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=BATCH_SIZE)
test_loader = DataLoader(test_dataset, batch_size=BATCH_SIZE)
print(f"训练集batch数: {len(train_loader)}")
14.7 第四步:三种模型实现
现在进入正题------用 CNN、LSTM、Transformer 三种架构分别实现情感分类器。同一个数据、同一个训练流程,只换模型,直观对比差异。
模型一:TextCNN(用 CNN 做文本分类)
python
import torch.nn as nn
import torch.nn.functional as F
class TextCNN(nn.Module):
"""
TextCNN:用卷积核提取文本的局部n-gram特征
原理:把文本当作一维序列(像图像的高度=1),
用不同大小的卷积核(2,3,4)分别提取2-gram、3-gram、4-gram特征,
再池化取最重要的特征,最后分类。
"""
def __init__(self, vocab_size, embed_dim=128, num_filters=100,
filter_sizes=[2, 3, 4], num_classes=2, dropout=0.3):
super().__init__()
# 词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
# 多个不同尺寸的卷积层
self.convs = nn.ModuleList([
nn.Conv1d(embed_dim, num_filters, kernel_size=k)
for k in filter_sizes
])
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(num_filters * len(filter_sizes), num_classes)
def forward(self, input_ids):
# input_ids: (batch, seq_len)
x = self.embedding(input_ids) # (batch, seq_len, embed_dim)
x = x.permute(0, 2, 1) # (batch, embed_dim, seq_len) ------ Conv1d需要的格式
# 多尺寸卷积 + 全局最大池化
conv_outs = []
for conv in self.convs:
c = F.relu(conv(x)) # (batch, num_filters, seq_len-k+1)
c = F.max_pool1d(c, c.size(2)).squeeze(2) # (batch, num_filters)
conv_outs.append(c)
# 拼接所有卷积结果
out = torch.cat(conv_outs, dim=1) # (batch, num_filters * len(filter_sizes))
out = self.dropout(out)
logits = self.fc(out) # (batch, num_classes)
return logits
TextCNN 大白话理解: 用 2-gram 卷积核看"两两搭配"(如"不好""好吃"),用 3-gram 看"三个字的搭配"(如"味道好""服务差"),每种卷积核取最突出的特征,拼在一起判断情感。快、简单、效果不差。
模型二:BiLSTM(双向 LSTM)
python
class BiLSTMClassifier(nn.Module):
"""
BiLSTM:双向LSTM读取整个序列,取最后一个时刻的隐藏状态分类
原理:正向LSTM从左到右读,逆向LSTM从右到左读,
拼接两个方向的隐藏状态,既看前文又看后文。
"""
def __init__(self, vocab_size, embed_dim=128, hidden_dim=128,
num_layers=2, num_classes=2, dropout=0.3):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
self.lstm = nn.LSTM(
input_size=embed_dim,
hidden_size=hidden_dim,
num_layers=num_layers,
batch_first=True,
bidirectional=True, # 双向
dropout=dropout if num_layers > 1 else 0
)
self.dropout = nn.Dropout(dropout)
# 双向所以 hidden_dim * 2
self.fc = nn.Linear(hidden_dim * 2, num_classes)
def forward(self, input_ids):
# input_ids: (batch, seq_len)
x = self.embedding(input_ids) # (batch, seq_len, embed_dim)
# LSTM: output所有时刻的隐藏状态, (h_n, c_n)最后一个时刻的
output, (h_n, c_n) = self.lstm(x)
# output: (batch, seq_len, hidden_dim*2)
# h_n: (num_layers*2, batch, hidden_dim)
# 取最后一层的正向和反向隐藏状态
# h_n[-2]是正向最后一个时刻, h_n[-1]是逆向最后一个时刻
hidden = torch.cat([h_n[-2], h_n[-1]], dim=1) # (batch, hidden_dim*2)
hidden = self.dropout(hidden)
logits = self.fc(hidden) # (batch, num_classes)
return logits
BiLSTM 大白话理解: 一个人从前往后读句子,另一个人从后往前读,最后两人把各自的理解合起来做判断。比单向 LSTM 看得更全面。
模型三:Transformer Encoder(简化版 BERT)
python
class TransformerClassifier(nn.Module):
"""
Transformer Encoder:用Self-Attention让每个词和所有词交互,
取<cls>位置的输出做分类(类似BERT)
原理:每个词同时关注所有词(全局视野),多层堆叠后,
<cls>位置的表示融合了整个句子的信息。
"""
def __init__(self, vocab_size, embed_dim=128, num_heads=4,
num_layers=4, ff_dim=256, num_classes=2,
max_len=MAX_LEN, dropout=0.1):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
# 可学习的位置编码
self.pos_embedding = nn.Embedding(max_len, embed_dim)
# Transformer Encoder层
encoder_layer = nn.TransformerEncoderLayer(
d_model=embed_dim,
nhead=num_heads,
dim_feedforward=ff_dim,
dropout=dropout,
batch_first=True,
activation="gelu" # GELU比ReLU效果更好
)
self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(embed_dim, num_classes)
def forward(self, input_ids):
# input_ids: (batch, seq_len)
batch_size, seq_len = input_ids.shape
# 生成padding mask:True的位置会被忽略(pad的位置)
pad_mask = (input_ids == 0) # (batch, seq_len)
# 词嵌入 + 位置嵌入
positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)
x = self.embedding(input_ids) + self.pos_embedding(positions)
# Transformer Encoder
x = self.transformer(x, src_key_padding_mask=pad_mask)
# 取第一个位置的输出做分类(类似BERT的[CLS])
cls_output = x[:, 0] # (batch, embed_dim)
cls_output = self.dropout(cls_output)
logits = self.fc(cls_output) # (batch, num_classes)
return logits
Transformer 大白话理解: 每个词都和所有词"开会讨论"(Self-Attention),讨论完更新自己的理解,然后进入下一层再讨论------多层讨论后,第一个位置的 [CLS] 融合了整个句子的信息,拿它来做分类。
14.8 第五步:统一训练框架
三种模型用同一套训练和评估代码,只换模型实例:
python
import time
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
class Trainer:
"""通用训练器:CNN/LSTM/Transformer都能用"""
def __init__(self, model, train_loader, val_loader, device,
lr=1e-3, model_name="model"):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.device = device
self.model_name = model_name
self.optimizer = torch.optim.Adam(model.parameters(), lr=lr)
self.criterion = nn.CrossEntropyLoss()
self.train_losses = []
self.val_accuracies = []
self.best_val_acc = 0
def train_epoch(self):
self.model.train()
total_loss = 0
for batch in self.train_loader:
input_ids = batch["input_ids"].to(self.device)
labels = batch["label"].to(self.device)
self.optimizer.zero_grad()
logits = self.model(input_ids)
loss = self.criterion(logits, labels)
loss.backward()
# 梯度裁剪(防止LSTM梯度爆炸)
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
self.optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(self.train_loader)
self.train_losses.append(avg_loss)
return avg_loss
@torch.no_grad()
def evaluate(self, loader=None):
if loader is None:
loader = self.val_loader
self.model.eval()
all_preds = []
all_labels = []
for batch in loader:
input_ids = batch["input_ids"].to(self.device)
labels = batch["label"].to(self.device)
logits = self.model(input_ids)
preds = logits.argmax(dim=1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
acc = accuracy_score(all_labels, all_preds)
return acc, all_preds, all_labels
def train(self, epochs=15, patience=5):
print(f"\n{'='*50}")
print(f"训练 {self.model_name}")
print(f"{'='*50}")
print(f"模型参数量: {sum(p.numel() for p in self.model.parameters()):,}")
no_improve = 0
for epoch in range(1, epochs + 1):
start = time.time()
# 训练
train_loss = self.train_epoch()
# 验证
val_acc, _, _ = self.evaluate()
self.val_accuracies.append(val_acc)
# 保存最佳模型
if val_acc > self.best_val_acc:
self.best_val_acc = val_acc
torch.save(self.model.state_dict(), f"best_{self.model_name}.pt")
no_improve = 0
marker = " *"
else:
no_improve += 1
marker = ""
elapsed = time.time() - start
print(f"Epoch {epoch:2d} | Loss: {train_loss:.4f} | "
f"Val Acc: {val_acc:.4f} | Time: {elapsed:.1f}s{marker}")
# 早停
if no_improve >= patience:
print(f"早停!验证集准确率{patience}轮未提升")
break
# 加载最佳模型
self.model.load_state_dict(torch.load(f"best_{self.model_name}.pt"))
print(f"训练完成!最佳验证准确率: {self.best_val_acc:.4f}")
return self.best_val_acc
14.9 第六步:训练三种模型并对比
python
# 确定设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备: {device}")
VOCAB_SIZE = len(vocab.word2idx)
EPOCHS = 15
# ========== 训练 TextCNN ==========
cnn_model = TextCNN(vocab_size=VOCAB_SIZE, embed_dim=128, num_filters=100,
filter_sizes=[2, 3, 4], num_classes=2, dropout=0.3)
cnn_trainer = Trainer(cnn_model, train_loader, val_loader, device,
lr=1e-3, model_name="TextCNN")
cnn_best = cnn_trainer.train(epochs=EPOCHS)
# ========== 训练 BiLSTM ==========
lstm_model = BiLSTMClassifier(vocab_size=VOCAB_SIZE, embed_dim=128, hidden_dim=128,
num_layers=2, num_classes=2, dropout=0.3)
lstm_trainer = Trainer(lstm_model, train_loader, val_loader, device,
lr=1e-3, model_name="BiLSTM")
lstm_best = lstm_trainer.train(epochs=EPOCHS)
# ========== 训练 Transformer ==========
tf_model = TransformerClassifier(vocab_size=VOCAB_SIZE, embed_dim=128, num_heads=4,
num_layers=4, ff_dim=256, num_classes=2,
max_len=MAX_LEN, dropout=0.1)
tf_trainer = Trainer(tf_model, train_loader, val_loader, device,
lr=5e-4, model_name="Transformer") # Transformer用小学习率
tf_best = tf_trainer.train(epochs=EPOCHS)
运行输出示例:
使用设备: cpu
==================================================
训练 TextCNN
==================================================
模型参数量: 398,322
Epoch 1 | Loss: 0.6921 | Val Acc: 0.7167 | Time: 1.2s *
Epoch 2 | Loss: 0.5432 | Val Acc: 0.8542 | Time: 1.1s *
Epoch 3 | Loss: 0.3214 | Val Acc: 0.9125 | Time: 1.2s *
...
Epoch 8 | Loss: 0.0521 | Val Acc: 0.9542 | Time: 1.1s *
训练完成!最佳验证准确率: 0.9542
==================================================
训练 BiLSTM
==================================================
模型参数量: 603,138
Epoch 1 | Loss: 0.6987 | Val Acc: 0.6833 | Time: 4.5s *
Epoch 2 | Loss: 0.6123 | Val Acc: 0.7917 | Time: 4.3s *
...
Epoch 12 | Loss: 0.0876 | Val Acc: 0.9458 | Time: 4.4s *
训练完成!最佳验证准确率: 0.9458
==================================================
训练 Transformer
==================================================
模型参数量: 845,314
Epoch 1 | Loss: 0.7012 | Val Acc: 0.6500 | Time: 2.8s *
Epoch 2 | Loss: 0.6534 | Val Acc: 0.7750 | Time: 2.7s *
...
Epoch 10 | Loss: 0.1234 | Val Acc: 0.9625 | Time: 2.8s *
训练完成!最佳验证准确率: 0.9625
在测试集上对比
python
from sklearn.metrics import classification_report
def test_model(trainer, model_name):
"""在测试集上评估"""
acc, preds, labels = trainer.evaluate(test_loader)
print(f"\n{model_name} 测试集结果:")
print(f" 准确率: {acc:.4f}")
print(classification_report(labels, preds,
target_names=["差评", "好评"],
digits=4))
return acc
print("\n" + "="*60)
print("测试集对比结果")
print("="*60)
cnn_test = test_model(cnn_trainer, "TextCNN")
lstm_test = test_model(lstm_trainer, "BiLSTM")
tf_test = test_model(tf_trainer, "Transformer")
对比总结
python
print("\n" + "="*60)
print("三种模型对比总结")
print("="*60)
print(f"{'模型':<15} {'参数量':>10} {'验证准确率':>10} {'测试准确率':>10} {'每轮时间':>10}")
print("-" * 60)
models_info = [
("TextCNN", sum(p.numel() for p in cnn_model.parameters()), cnn_best, cnn_test, "1.1s"),
("BiLSTM", sum(p.numel() for p in lstm_model.parameters()), lstm_best, lstm_test, "4.4s"),
("Transformer", sum(p.numel() for p in tf_model.parameters()), tf_best, tf_test, "2.8s"),
]
for name, params, val_acc, test_acc, t in models_info:
print(f"{name:<15} {params:>10,} {val_acc:>10.4f} {test_acc:>10.4f} {t:>10}")
输出示例:
============================================================
三种模型对比总结
============================================================
模型 参数量 验证准确率 测试准确率 每轮时间
------------------------------------------------------------
TextCNN 398,322 0.9542 0.9500 1.1s
BiLSTM 603,138 0.9458 0.9417 4.4s
Transformer 845,314 0.9625 0.9583 2.8s
三种模型该怎么选
| 维度 | TextCNN | BiLSTM | Transformer |
|---|---|---|---|
| 速度 | 最快 | 最慢(串行) | 中等 |
| 参数效率 | 最高(参数少效果不差) | 中 | 最低(需要更多参数) |
| 效果上限 | 中(短文本够用) | 中 | 最高 |
| 长文本能力 | 弱(只看局部n-gram) | 中(能记忆但会衰减) | 强(全局视野) |
| 适合场景 | 短文本分类、快速baseline | 时间序列、小数据 | 大数据、复杂任务、长文本 |
| 推荐程度 | 简单任务首选 | 特定场景 | 大数据/复杂任务首选 |
实际建议:
- 短文本分类(评论、标题):TextCNN 性价比最高,快又够用
- 需要理解上下文(问答、阅读理解):Transformer
- 时间序列预测(股票、传感器):LSTM 仍有用武之地
- 有大量数据:直接上 Transformer,数据越多优势越明显
- 数据少 + 任务简单:TextCNN,别过度设计
14.10 第七步:可视化训练过程
python
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 训练损失对比
axes[0].plot(cnn_trainer.train_losses, label="TextCNN", marker="o")
axes[1].plot(lstm_trainer.train_losses, label="BiLSTM", marker="s")
axes[0].plot(tf_trainer.train_losses, label="Transformer", marker="^")
axes[0].set_title("Training Loss")
axes[0].set_xlabel("Epoch")
axes[0].set_ylabel("Loss")
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 验证准确率对比
axes[1].plot(cnn_trainer.val_accuracies, label="TextCNN", marker="o")
axes[1].plot(lstm_trainer.val_accuracies, label="BiLSTM", marker="s")
axes[1].plot(tf_trainer.val_accuracies, label="Transformer", marker="^")
axes[1].set_title("Validation Accuracy")
axes[1].set_xlabel("Epoch")
axes[1].set_ylabel("Accuracy")
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("training_comparison.png", dpi=150)
plt.show()
print("训练对比图已保存: training_comparison.png")
从图中能直观看到:
- TextCNN 收敛最快(2-3个Epoch就到90%+),但准确率上限低
- BiLSTM 收敛最慢,波动大,但最终效果不错
- Transformer 前几个Epoch慢(词嵌入和Attention权重需要预热),但后期反超
14.11 第八步:模型部署------把模型变成 API
训练完模型不是终点,能用起来才是。用 FastAPI 把最好的模型(Transformer)部署成在线服务:
python
# app.py ------ FastAPI 推理服务
from fastapi import FastAPI
from pydantic import BaseModel
import torch
import jieba
app = FastAPI(title="餐厅评论情感分析API")
# 加载模型和词表(实际项目中词表也要保存)
model = TransformerClassifier(
vocab_size=VOCAB_SIZE, embed_dim=128, num_heads=4,
num_layers=4, ff_dim=256, num_classes=2,
max_len=MAX_LEN, dropout=0.1
)
model.load_state_dict(torch.load("best_Transformer.pt"))
model.eval()
class ReviewRequest(BaseModel):
text: str
class ReviewResponse(BaseModel):
text: str
sentiment: str # "好评" or "差评"
confidence: float
@app.post("/predict", response_model=ReviewResponse)
def predict(req: ReviewRequest):
# 预处理
ids = vocab.encode(req.text, max_len=MAX_LEN)
input_ids = torch.tensor([ids], dtype=torch.long)
# 推理
with torch.no_grad():
logits = model(input_ids)
probs = torch.softmax(logits, dim=1)
pred = probs.argmax(dim=1).item()
confidence = probs[0][pred].item()
return ReviewResponse(
text=req.text,
sentiment="好评" if pred == 1 else "差评",
confidence=round(confidence, 4)
)
# 启动: uvicorn app:app --reload --port 8000
测试 API:
bash
# 启动服务
uvicorn app:app --reload --port 8000
# 另一个终端测试
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"text": "这家火锅味道不错,服务也很好,下次还来!"}'
# 返回
# {"text": "...", "sentiment": "好评", "confidence": 0.9732}
curl -X POST http://localhost:8000/predict \
-H "Content-Type: application/json" \
-d '{"text": "等了半小时才上菜,菜还是凉的,再也不来了。"}'
# 返回
# {"text": "...", "sentiment": "差评", "confidence": 0.9651}
14.12 第九步:模型保存与加载
python
# ========== 保存 ==========
# 保存完整checkpoint(推荐)
checkpoint = {
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"vocab": vocab.word2idx,
"config": {
"vocab_size": VOCAB_SIZE,
"embed_dim": 128,
"num_heads": 4,
"num_layers": 4,
"ff_dim": 256,
"max_len": MAX_LEN,
},
"best_val_acc": tf_best,
}
torch.save(checkpoint, "sentiment_model_full.pt")
print("完整模型已保存")
# ========== 加载 ==========
def load_model(checkpoint_path, device="cpu"):
checkpoint = torch.load(checkpoint_path, map_location=device)
config = checkpoint["config"]
model = TransformerClassifier(**config)
model.load_state_dict(checkpoint["model_state_dict"])
model.to(device)
model.eval()
# 恢复词表
vocab = VocabBuilder()
vocab.word2idx = checkpoint["vocab"]
vocab.idx2word = {v: k for k, v in vocab.word2idx.items()}
return model, vocab, config
model, vocab, config = load_model("sentiment_model_full.pt")
print("模型加载成功!")
14.13 深度学习实战避坑指南
踩过的坑比学过的课更有价值。以下是用 PyTorch 做深度学习项目最容易踩的坑:
坑一:数据泄漏
python
# ❌ 错误:先做特征工程再划分数据集
all_data = preprocess(all_data) # 用了全局统计量(如均值、方差)
train, test = split(all_data) # 测试集"见过"训练集的信息
# ✅ 正确:先划分再预处理
train, test = split(raw_data)
train_data = preprocess(train) # 只用训练集的统计量
test_data = preprocess(test) # 用训练集的统计量transform测试集
坑二:忘记 model.train() 和 model.eval()
python
# ❌ 验证时忘记切换eval模式
model.train() # Dropout和BatchNorm还在工作!
val_acc = evaluate(model, val_loader) # 结果不可靠
# ✅ 正确
model.eval() # 关闭Dropout,BatchNorm用累计统计量
with torch.no_grad(): # 不计算梯度,省内存加速
val_acc = evaluate(model, val_loader)
坑三:学习率太大或太小
python
# 学习率太大 → loss变成NaN或震荡不收敛
# 学习率太小 → 收敛太慢或卡在局部最优
# ✅ 实用策略:用学习率调度器
from torch.optim.lr_scheduler import CosineAnnealingLR
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
scheduler = CosineAnnealingLR(optimizer, T_max=epochs)
for epoch in range(epochs):
train_epoch()
scheduler.step() # 学习率按余弦曲线衰减
坑四:没做梯度裁剪(RNN/LSTM 必做)
python
# ❌ 不裁剪 → LSTM/Transformer可能梯度爆炸
loss.backward()
optimizer.step()
# ✅ 裁剪
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
坑五:Embedding 没设 padding_idx
python
# ❌ PAD位置的嵌入也会被训练,浪费且可能引入噪声
self.embedding = nn.Embedding(vocab_size, embed_dim)
# ✅ 指定padding_idx,PAD位置的嵌入始终为0
self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
坑六:Batch Size 随便设
python
# 太大 → 显存溢出(OOM)
# 太小 → 梯度噪声大,训练不稳定
# 经验值:32 或 64 是黄金大小,GPU 显存不够就 16
# 动态调整 batch size 的技巧
try:
train_batch(batch_size=64)
except RuntimeError: # OOM
torch.cuda.empty_cache()
train_batch(batch_size=32)
坑七:只用准确率评估
python
# 如果数据不平衡(如9:1),全预测多数类准确率就有90%
# ✅ 必须看 F1 和混淆矩阵
from sklearn.metrics import classification_report, confusion_matrix
print(classification_report(labels, preds))
print(confusion_matrix(labels, preds))
14.14 PyTorch 速查表
核心操作
python
# === 张量操作 ===
x = torch.randn(32, 128) # 创建随机张量
x = x.to(device) # 移到GPU
x = x.view(32, -1) # 改形状
x = x.permute(0, 2, 1) # 维度交换
x = x.unsqueeze(0) # 增加维度
x = x.squeeze() # 去掉大小为1的维度
x = x.detach().cpu().numpy() # 转numpy
# === 模型 ===
model = MyModel().to(device) # 创建并移到GPU
model.train() / model.eval() # 切换模式
model.parameters() # 获取所有参数
model.named_parameters() # 带名字的参数
sum(p.numel() for p in model.parameters()) # 参数量
# === 训练 ===
optimizer.zero_grad() # 清梯度
output = model(input) # 前向传播
loss = criterion(output, target) # 算损失
loss.backward() # 反向传播
optimizer.step() # 更新参数
# === 保存加载 ===
torch.save(model.state_dict(), "model.pt") # 保存
model.load_state_dict(torch.load("model.pt")) # 加载
torch.save(model, "full_model.pt") # 保存整个模型
model = torch.load("full_model.pt") # 加载整个模型
# === GPU相关 ===
torch.cuda.is_available() # GPU是否可用
torch.cuda.empty_cache() # 清空GPU缓存
torch.cuda.memory_allocated() # 已分配显存
with torch.cuda.amp.autocast(): # 混合精度训练(省显存加速)
output = model(input)
常用 Layer 速查
python
nn.Linear(in, out) # 全连接
nn.Embedding(vocab, dim) # 词嵌入
nn.Conv1d / Conv2d # 卷积
nn.LSTM / nn.GRU # 循环网络
nn.TransformerEncoderLayer # Transformer层
nn.LayerNorm / nn.BatchNorm1d # 归一化
nn.Dropout(0.3) # 正则化
nn.ReLU / nn.GELU / nn.SiLU # 激活函数
nn.Softmax(dim=-1) # 概率分布
nn.CrossEntropyLoss() # 分类损失
nn.MSELoss() # 回归损失
14.15 从这个项目到大模型
这个项目用的是从零训练的小模型------词表只有几百个,参数量不到 100 万,在 1600 条数据上训练。
真实的大模型(如 Qwen-72B)和这个项目的区别:
| 维度 | 本项目 | 大模型 |
|---|---|---|
| 参数量 | ~80万 | 720亿 |
| 训练数据 | 1600条 | 万亿Token |
| 词表大小 | 287 | 15万+ |
| 训练成本 | CPU 几分钟 | 数千张GPU训练数月 |
| 架构 | Transformer Encoder | Transformer Decoder + RoPE + GQA + SwiGLU |
| 词嵌入 | 从零学习 | 从零学习(但用BPE分词) |
| 能力 | 只能做二分类 | 聊天/写代码/推理/多语言 |
但核心原理完全一样:词嵌入 → Transformer 层 → 输出概率分布。区别只在于规模和工程优化。
理解了这个项目,你就理解了大模型训练的"骨架"。大模型多出来的东西(RoPE、GQA、SwiGLU、KV Cache),都是在这个骨架上的"装修升级"。
14.16 本章小结
完整项目回顾
我们用 PyTorch 做了一个端到端的中文情感分析项目,从数据到部署走了 9 步:
数据准备 → 分词词表 → DataLoader → 三种模型实现 → 统一训练 → 对比评估 → 可视化 → API部署 → 模型保存
三种架构对比结论
| 结论 | 说明 |
|---|---|
| TextCNN 是最佳 baseline | 参数少、速度快、短文本效果不差,先用它跑通 |
| BiLSTM 适合序列记忆 | 但训练慢、效果不一定更好,新项目不推荐首选 |
| Transformer 上限最高 | 数据足够多时优势明显,是当前AI主流架构 |
| 先简单后复杂 | 先 TextCNN 跑通全流程,再换 Transformer 追求精度 |
深度学习阶段的总结
第3阶段「深度学习」5 章全部完成:
| 章节 | 核心知识 |
|---|---|
| 第10章 神经网络原理 | 前向传播、反向传播、梯度下降、优化器 |
| 第11章 CNN卷积网络 | 卷积层、池化层、参数共享、图像识别 |
| 第12章 RNN循环网络 | 隐藏状态、LSTM三门机制、序列建模 |
| 第13章 Transformer | Self-Attention、Multi-Head、位置编码 |
| 第14章 框架实操 | PyTorch端到端项目、三种模型对比、部署 |
到这里,你已经具备了从零搭建深度学习模型的全部基础知识。
接下来进入第4阶段「大模型(LLM)」------从 GPT 的工作原理讲起,进入大模型应用开发的核心领域。第15章会讲清楚大模型到底是"背答案"还是"真理解",以及 Next Token Prediction 为什么能产生如此惊人的能力。
动手作业:
- 把本章代码跑一遍,确保三种模型都能训练成功
- 尝试修改超参数(学习率、batch size、层数),观察效果变化
- 用真实数据集(ChnSentiCorp)替换模拟数据,看效果差异
- 给 Transformer 模型加上 RoPE 位置编码,对比效果