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

第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 环境准备

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 为什么能产生如此惊人的能力。


动手作业:

  1. 把本章代码跑一遍,确保三种模型都能训练成功
  2. 尝试修改超参数(学习率、batch size、层数),观察效果变化
  3. 用真实数据集(ChnSentiCorp)替换模拟数据,看效果差异
  4. 给 Transformer 模型加上 RoPE 位置编码,对比效果
相关推荐
数字供应链安全产品选型2 小时前
悬镜安全打造“中国版 Anthropic Mythos”:以AI治理AI,重构新一代数字供应链安全
人工智能·安全·重构
Tangyuewei3 小时前
2026 下半年:从更快到更可控
人工智能
qq_316411033 小时前
AI 情感陪伴智能潮玩软硬件一体化开发案例
人工智能·python
Hyyy3 小时前
AI基础——机器学习
人工智能·ai编程
卡梅德生物科技小能手3 小时前
卡梅德生物科普|TSLP(胸腺基质淋巴细胞生成素)靶点研究概述
经验分享·深度学习·生活
一名普通的电源工程师3 小时前
E0512XT-1WR3 适配优选 钡特电源 DF1-05D12XT|1W 隔离5V转±12V模块电源选型参数技术解析
大数据·网络·人工智能
zx1154503 小时前
大模型工具调用次数限制
人工智能·python
windliang3 小时前
Claude Code 源码分析(五):一次 Tool 调用怎样通过权限检查
前端·人工智能·面试
2601_961391463 小时前
杭州极序时代|GEO的对抗防御与幻觉免疫工程
人工智能·极序时代geo
Think_Higher3 小时前
CID行业趋势周报|7.27—8.02:大盘表现、重点赛道与投测建议
大数据·人工智能