第14章 框架实操:用 PyTorch 跑通一个完整深度学习项目

一句话点题: 前几章你学了神经网络、CNN、RNN、Transformer,这章我们把它们全串起来,从零到部署跑完一个真实项目。


14.1 为什么选 PyTorch

深度学习框架很多,但 2024 年的格局已经很清楚了:

框架地位特点
PyTorch学术界 + 开源界绝对主流动态图,调试友好,Pythonic
TensorFlow工业界仍有存量,新项目越来越少静态图(TF2也支持动态),部署生态好
JAX前沿研究领域兴起函数式编程,自动并行,学习曲线陡
PaddlePaddle国产框架,百度系中文文档好,国内政策支持
MindSpore华为出品昇腾芯片适配

为什么推荐 PyTorch?

  1. 大模型生态全在 PyTorch 上:Hugging Face Transformers、LLaMA、Qwen、Stable Diffusion 全是 PyTorch
  2. 调试直观:动态图意味着你可以用 print() 看每一步的值,跟写普通 Python 一样
  3. 社区资源最多:论文开源代码 90% 是 PyTorch
  4. 学习曲线友好:API 设计贴近 Python 直觉

本章所有代码基于 PyTorch 2.x,Python 3.10+。


14.2 项目背景:中文情感分析

我们要做一个中文餐厅评论情感分析系统——输入一条评论,判断是好评还是差评。

为什么选这个项目

  1. 够典型:文本分类是 NLP 的"Hello World"
  2. 能对比三种架构:同一个任务用 CNN、LSTM、Transformer 分别实现,直观感受差异
  3. 能跑起来:数据集小,普通笔记本 CPU 就能跑
  4. 有实际价值:情感分析是电商、餐饮、舆情监控的基础能力

项目目标

  • 输入:“这家火锅味道不错,服务也很好,下次还来!” → 输出:好评(置信度 0.95)
  • 输入:“等了半小时才上菜,菜还是凉的,再也不来了。” → 输出:差评(置信度 0.92)

14.3 环境准备

# 创建虚拟环境
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

验证安装:

import torch
print(f"PyTorch 版本: {torch.__version__}")
print(f"CUDA 可用: {torch.cuda.is_available()}")
# 如果有GPU: True,没有也无所谓,这个项目CPU也能跑

14.4 第一步:准备数据

生成模拟数据集

真实项目用公开数据集(如 ChnSentiCorp),这里为了让你能直接跑,生成一份模拟数据:

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

数据探索

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 第二步:数据预处理——分词 + 词表

中文和英文不同,英文天然有空格分词,中文需要用分词工具。

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

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 做文本分类)

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)

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)

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 第五步:统一训练框架

三种模型用同一套训练和评估代码,只换模型实例:

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 第六步:训练三种模型并对比

# 确定设备
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

在测试集上对比

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")

对比总结

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

三种模型该怎么选

维度TextCNNBiLSTMTransformer
速度最快最慢(串行)中等
参数效率最高(参数少效果不差)最低(需要更多参数)
效果上限中(短文本够用)最高
长文本能力弱(只看局部n-gram)中(能记忆但会衰减)(全局视野)
适合场景短文本分类、快速baseline时间序列、小数据大数据、复杂任务、长文本
推荐程度简单任务首选特定场景大数据/复杂任务首选

实际建议:

  • 短文本分类(评论、标题):TextCNN 性价比最高,快又够用
  • 需要理解上下文(问答、阅读理解):Transformer
  • 时间序列预测(股票、传感器):LSTM 仍有用武之地
  • 有大量数据:直接上 Transformer,数据越多优势越明显
  • 数据少 + 任务简单:TextCNN,别过度设计

14.10 第七步:可视化训练过程

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)部署成在线服务:

# 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:

# 启动服务
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 第九步:模型保存与加载

# ========== 保存 ==========
# 保存完整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 做深度学习项目最容易踩的坑:

坑一:数据泄漏

# ❌ 错误:先做特征工程再划分数据集
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()

# ❌ 验证时忘记切换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)

坑三:学习率太大或太小

# 学习率太大 → 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 必做)

# ❌ 不裁剪 → 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

# ❌ 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 随便设

# 太大 → 显存溢出(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)

坑七:只用准确率评估

# 如果数据不平衡(如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 速查表

核心操作

# === 张量操作 ===
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 速查

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
词表大小28715万+
训练成本CPU 几分钟数千张GPU训练数月
架构Transformer EncoderTransformer 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章 TransformerSelf-Attention、Multi-Head、位置编码
第14章 框架实操PyTorch端到端项目、三种模型对比、部署

到这里,你已经具备了从零搭建深度学习模型的全部基础知识。

接下来进入第4阶段「大模型(LLM)」——从 GPT 的工作原理讲起,进入大模型应用开发的核心领域。第15章会讲清楚大模型到底是"背答案"还是"真理解",以及 Next Token Prediction 为什么能产生如此惊人的能力。


动手作业:

  1. 把本章代码跑一遍,确保三种模型都能训练成功
  2. 尝试修改超参数(学习率、batch size、层数),观察效果变化
  3. 用真实数据集(ChnSentiCorp)替换模拟数据,看效果差异
  4. 给 Transformer 模型加上 RoPE 位置编码,对比效果

更多推荐