跳转到内容

7:循环神经网络实战项目

机器学习Banner

恭喜你!🎉 在经过上一个题目的深入学习后,你已经掌握了循环神经网络的核心知识。现在是时候将理论付诸实践,构建一个真正能够理解人类情感的 AI 系统了!

这个项目将带你从理论走向应用,体验 AI 工程师的真实工作流程——从数据处理到模型评估


在第 6 题中,你实现了 SimpleRNN 和 LSTM 单元,并用它们预测了数学序列。但真实的 NLP 任务远比正弦波复杂:

对比第 6 题实验第 7 题实战
数据1 维标量序列文本序列(需 tokenize + embedding)
任务回归(预测下一个值)分类(正面/负面情感)
维度(seq_len, 1)(batch, seq_len)(batch, seq_len, embed_dim)
挑战梯度消失数据预处理 + 模型设计 + 训练调参

本题你将完成一个完整的文本分类项目:从原始 CSV 到可运行的情感分析系统,测试集准确率目标 85%+

思考(不必写进报告,但请带着问题做题):

  • 文本是字符串,怎么变成 RNN 能处理的数字?
  • 为什么需要 Embedding 层?直接用 one-hot 行不行?
  • 双向 LSTM 比单向好在哪?(呼应第 6 题 Q5)

下载:数据文件请到 QQ 群下载 IMDB Dataset 文件夹。

数据格式IMDB Dataset.csv):

reviewsentiment
”One of the other reviewers has mentioned that…“positive
”A wonderful little production…“positive
”I thought this was a wonderful way to…“positive
”Basically there’s a family where…“negative
  • 数据量:50,000 条评论(25k positive + 25k negative,类别平衡)
  • 任务:二分类(positive / negative)
  • 目标:测试集准确率 85%+

Part 2:数据预处理(你需要自己实现)

Section titled “Part 2:数据预处理(你需要自己实现)”

⚠️ 文本不能直接喂给 RNN,必须经过”清洗 → 建词汇表 → 转索引 → 填充”四步变成数字张量。本题只给框架,核心逻辑你需要自己实现。

💡 用 DataProcessor 类把 word2idx 存在 self 里,避免函数之间到处传参。fit 只在训练集上调用一次,transform 用于把任意文本转成等长数组。

2.1 DataProcessor 类(你需要实现核心方法)

Section titled “2.1 DataProcessor 类(你需要实现核心方法)”
import re
import numpy as np
from collections import Counter
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split # 若无 sklearn 可自己实现 split
class DataProcessor:
def __init__(self, max_vocab_size=10000, max_seq_length=128):
self.max_vocab_size = max_vocab_size
self.max_seq_length = max_seq_length
# 预留两个特殊 token
self.word2idx = {'<PAD>': 0, '<UNK>': 1}
self.idx2word = {0: '<PAD>', 1: '<UNK>'}
def clean_text(self, text):
"""
清洗文本:先去 HTML 标签 → 转小写 → 去标点 → 规范化空格
注意操作顺序:必须先去 <br /> 再去标点,否则 <br /> 里的字母会残留成 "br"
输入: "This movie is <br /> great!! I love it."
期望输出: "this movie is great i love it"
"""
# TODO: 你的实现
# 提示(按顺序):
# 1. re.sub(r'<br\s*/?>', ' ', text) 把 <br /> 换成空格
# 2. .lower() 转小写
# 3. re.sub(r'[^a-z\s]', ' ', text) 非字母转空格(去标点)
# 4. ' '.join(text.split()) 规范化多个空格为一个
pass
def build_vocab(self, texts):
"""
在训练集上构建词汇表 (写入 self.word2idx / self.idx2word)
要求:
- <PAD>=0, <UNK>=1 已在 __init__ 中预留,不要重复加
- 用 Counter 统计词频,取最常见的 max_vocab_size - 2 个词
- 实际词的索引从 2 开始
"""
# TODO: 你的实现
pass
def text_to_sequence(self, text):
"""
文本 → 索引序列 (用 self.word2idx)
遇到词汇表外的词用 <UNK>(=1) 代替
输入: "this movie is great"
输出: [2, 3, 4, 5] (假设这些词都在词汇表中)
"""
# TODO: 你的实现
pass
def pad_sequences(self, sequences):
"""
序列填充/截断到 self.max_seq_length (前面补 0, 末尾保留)
策略:
- 长度 < max_len: 前面补 0, 有效内容靠右
- 长度 > max_len: 截断保留末尾 (开头被截掉)
示例 (max_len=4):
输入: [[2,3], [4,5,6,7,8]]
输出: [[0,0,2,3], [5,6,7,8]]
⚠️ 关键: 返回 numpy.ndarray, 形状 [n, max_len], dtype=int64
(因为 torch.LongTensor 要求规则形状, 不能直接转 list of 变长 lists)
"""
# TODO: 你的实现
# 提示:
# padded = np.zeros((len(sequences), self.max_seq_length), dtype=np.int64)
# for i, seq in enumerate(sequences):
# if len(seq) == 0: continue
# if len(seq) >= self.max_seq_length:
# padded[i] = seq[-self.max_seq_length:] # 保留末尾
# else:
# padded[i, -len(seq):] = seq # 前面补 0
pass
def fit(self, train_texts):
"""在训练集上构建词汇表(只在训练集调用一次)"""
cleaned = [self.clean_text(t) for t in train_texts]
self.build_vocab(cleaned)
return self
def transform(self, texts):
"""把文本列表转为 pad 后的 numpy array (给 SentimentDataset 用)"""
cleaned = [self.clean_text(t) for t in texts]
sequences = [self.text_to_sequence(t) for t in cleaned]
return self.pad_sequences(sequences)

思考

  • 为什么词汇表只在训练集上构建?(防止数据泄漏)
  • <PAD><UNK> 的作用分别是什么?
  • max_vocab_size 太大/太小会怎样?
  • 为什么要在前面补 0?(提示:RNN 最后一步的隐藏状态最能代表整个序列,前面补 0 会让真实内容靠后,更接近最后一步)
class SentimentDataset(Dataset):
"""
⚠️ 关键: 传入的 texts 必须已经是 pad 后的等长 array
(torch.LongTensor 要求规则形状, 不能是 list of 变长 lists)
要求:
- __init__: texts → torch.LongTensor, labels → torch.LongTensor
- __len__ / __getitem__
"""
def __init__(self, texts, labels):
# TODO: 你的实现
# self.texts = torch.LongTensor(texts)
# self.labels = torch.LongTensor(labels)
pass
def __len__(self): pass
def __getitem__(self, idx): pass

2.3 create_data_loaders(明确返回 processor)

Section titled “2.3 create_data_loaders(明确返回 processor)”
def create_data_loaders(texts, labels, batch_size=32):
"""
划分 train/val/test = 80/10/10, 创建 DataLoader
返回: (train_loader, val_loader, test_loader, processor)
processor 里有 word2idx, 模型需要用 len(processor.word2idx) 作为 vocab_size
步骤:
1. train_test_split 切出 10% test
2. 再在剩下的里切出 1/9 作为 val (1/9 * 0.9 = 0.1)
3. DataProcessor.fit(X_train) # 只在训练集上建词表
4. processor.transform 转换三份数据
5. 包成 SentimentDataset + DataLoader
(train shuffle=True, val/test shuffle=False)
"""
# TODO: 你的实现
pass

2.4 评估工具(已提供,直接使用)

Section titled “2.4 评估工具(已提供,直接使用)”

我们提供 calculate_metricsplot_confusion_matrix 函数,你不需要自己实现,直接调用即可。

"""别忘了在环境里装好 sklearn和seaborn"""
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix
import matplotlib.pyplot as plt
import seaborn as sns
def calculate_metrics(y_true, y_pred, num_classes):
"""计算所有评估指标"""
accuracy = accuracy_score(y_true, y_pred)
average = 'binary' if num_classes == 2 else 'macro'
precision = precision_score(y_true, y_pred, average=average, zero_division=0)
recall = recall_score(y_true, y_pred, average=average, zero_division=0)
f1 = f1_score(y_true, y_pred, average=average, zero_division=0)
return {'accuracy': accuracy, 'precision': precision, 'recall': recall, 'f1_score': f1}
def plot_confusion_matrix(y_true, y_pred, class_names=None):
"""绘制混淆矩阵"""
cm = confusion_matrix(y_true, y_pred)
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=class_names, yticklabels=class_names)
plt.title('Confusion Matrix')
plt.ylabel('True Label')
plt.xlabel('Predicted Label')
plt.show()
return cm
def plot_training_history(train_losses, val_losses, train_accs, val_accs):
"""绘制训练曲线"""
epochs = range(1, len(train_losses) + 1)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(epochs, train_losses, 'b-', label='Training Loss')
ax1.plot(epochs, val_losses, 'r-', label='Validation Loss')
ax1.set_title('Training and Validation Loss')
ax1.set_xlabel('Epochs'); ax1.set_ylabel('Loss'); ax1.legend()
ax2.plot(epochs, train_accs, 'b-', label='Training Accuracy')
ax2.plot(epochs, val_accs, 'r-', label='Validation Accuracy')
ax2.set_title('Training and Validation Accuracy')
ax2.set_xlabel('Epochs'); ax2.set_ylabel('Accuracy'); ax2.legend()
plt.tight_layout(); plt.show()

import torch
import torch.nn as nn
class BiLSTMClassifier(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_layers,
num_classes=2, dropout=0.3, pad_idx=0):
super().__init__()
# TODO: 实现以下层
#
# 1. Embedding 层
# nn.Embedding(vocab_size, embed_dim, padding_idx=pad_idx)
# 作用: 把词索引转为向量
# 维度: [batch, seq_len] -> [batch, seq_len, embed_dim]
#
# 2. 双向 LSTM
# nn.LSTM(embed_dim, hidden_dim, num_layers,
# batch_first=True, bidirectional=True, dropout=dropout)
# 关键参数:
# - batch_first=True: 输入形状是 [batch, seq_len, embed_dim]
# - bidirectional=True: 双向
# 输出:
# - output: [batch, seq_len, hidden_dim * 2] (双向拼接)
# - (h_n, c_n): 各层最后的隐藏状态
#
# 3. Dropout 层
# nn.Dropout(dropout)
#
# 4. 全连接层
# nn.Linear(hidden_dim * 2, num_classes)
# 注意: 输入维度是 hidden_dim * 2,因为双向拼接
pass
def forward(self, x):
"""
前向传播
x: [batch_size, seq_len] (词索引序列)
维度追踪(请在代码中注释每一步的 shape):
x: [batch, seq_len]
embedded: [batch, seq_len, embed_dim]
lstm_out: [batch, seq_len, hidden_dim * 2]
pooled: [batch, hidden_dim * 2] (如何从序列维度提取?
提示: 取最后一个时间步,或做 max-pooling/mean-pooling)
output: [batch, num_classes]
"""
# TODO: 实现前向传播
# 关键问题:
# 1. LSTM 输出是 [batch, seq_len, hidden_dim*2],怎么变成 [batch, hidden_dim*2]?
# 方案 A: lstm_out[:, -1, :] (取最后时间步)
# 方案 B: lstm_out.max(dim=1)[0] (max-pooling)
# 方案 C: lstm_out.mean(dim=1) (mean-pooling)
# 方案 D: 用 h_n (最后一层隐藏状态)
# 尝试不同方案,比较效果
#
# 2. 双向 LSTM 的 h_n 形状是 [num_layers*2, batch, hidden_dim]
# 怎么取出最后一层的前向和后向隐藏状态并拼接?
pass

实现以下变体并对比:

  • 单向 LSTM vs 双向 LSTM
  • 1 层 vs 2 层 vs 3 层
  • Max-pooling vs Mean-pooling vs 最后时间步
  • LSTM vs GRU

class SentimentTrainer:
def __init__(self, model, lr=1e-3, device='cuda' if torch.cuda.is_available() else 'cpu'):
self.model = model.to(device)
self.device = device
# TODO: 初始化优化器和损失函数
# 推荐配置:
# optimizer = torch.optim.Adam(model.parameters(), lr=lr)
# criterion = nn.CrossEntropyLoss()
# 思考: 为什么分类任务用 CrossEntropy 而不是 MSE?
pass
def train_epoch(self, train_loader):
"""训练一个 epoch,返回 (平均loss, 准确率)"""
# TODO: 实现训练循环
#
# ⚠️ 关键陷阱(踩过才知道疼):
# 1. 每个 batch 开始前必须 optimizer.zero_grad()
# 否则梯度会累积,模型发疯
# 2. 必须先 model.train()(开启 dropout 和 BN)
# 3. 数据要 .to(device)
# 4. loss.backward() 之后才能 optimizer.step()
# 5. 不要忘记 torch.no_grad() 在评估时(下面)
#
# 步骤:
# for x, y in train_loader:
# x, y = x.to(device), y.to(device)
# optimizer.zero_grad()
# output = model(x)
# loss = criterion(output, y)
# loss.backward()
# optimizer.step()
pass
def evaluate(self, data_loader):
"""评估,返回 (平均loss, 预测列表, 真实标签列表)"""
# TODO: 实现评估循环
#
# ⚠️ 关键陷阱:
# 1. 必须 model.eval()(关闭 dropout 和 BN)
# 2. 必须 with torch.no_grad():(节省内存 + 加速)
# 3. 不要在这里 zero_grad / backward / step
pass
def train(self, train_loader, val_loader, num_epochs=10):
"""完整训练流程,返回训练历史"""
# TODO: 实现完整训练循环
#
# 建议实现:
# 1. 每个 epoch 训练 + 验证
# 2. 记录 loss 和 accuracy
# 3. [选做] 早停: 验证 loss 连续 3 个 epoch 不降就停
# 4. [选做] 学习率调度: ReduceLROnPlateau
# 5. [选做] 保存最佳模型
pass
def main():
# 1. 加载数据
# 用 pandas 读 CSV, 提取 review 和 sentiment 两列
# 把 sentiment 映射为 0 (negative) / 1 (positive)
# 2. 数据预处理
# 用 DataProcessor: fit(train) -> transform(train/val/test)
# 创建 DataLoader
# 3. 创建模型
# vocab_size = len(processor.word2idx) # 从 processor 拿
# 推荐超参:
# embed_dim = 128 或 256
# hidden_dim = 128 或 256
# num_layers = 1 或 2
# dropout = 0.3
# 4. 训练
# num_epochs = 10 (先用少量 epoch 跑通,再加)
# 5. 测试
# 在 test_loader 上评估
# 打印 accuracy, precision, recall, f1
# 绘制混淆矩阵和训练曲线

基础模型达到 85% 后,尝试以下优化:

  • 调整 hidden_dim / num_layers / dropout
  • 用 GRU 替代 LSTM,比较速度和精度
  • 加 Attention 层(选做,为第 8 题 Transformer 预热)
  • 用预训练词向量(Word2Vec / GloVe)替代随机初始化 Embedding (提示:torchtext 或 gensim 可以加载预训练向量)
  • 文本增强:随机删除/替换词
  • 学习率调度:ReduceLROnPlateau / CosineAnnealingLR
  • 权重衰减:optimizer 加 weight_decay=1e-5
  • 梯度裁剪:torch.nn.utils.clip_grad_norm_(防止梯度爆炸)

完成项目后,回答以下问题(每题 100-200 字):

Q1:你的模型在哪些样本上容易判断错误?分析错误样本的特征(如讽刺、否定词、长评论等)。

Q2:双向 LSTM 比单向 LSTM 好在哪?用你的实验数据说明(对比两者的 test accuracy)。 (呼应第 6 题 Q5:双向 LSTM 在什么场景不能用?)

Q3:如果让你用这个模型做”实时评论审核”(用户提交评论后立即判断),你会选单向还是双向 LSTM?为什么?

  1. 数据探索(数据量、类别分布、文本长度分布)
  2. 预处理方案(清洗、分词、词汇表、填充策略)
  3. 模型结构(BiLSTM 架构图、超参数选择理由)
  4. 训练过程(训练曲线、调参历程)
  5. 评估结果(accuracy/precision/recall/f1、混淆矩阵、错误分析)
  6. 改进策略(尝试了什么、效果如何)
  7. 深度思考(Part 6 的 3 个问题)
  8. 收获与反思
  9. AI 使用说明

报告提示:用你自己的语言来描述这个项目!分享你的思考过程、遇到的困难以及解决问题时的成就感。过程>>结果, 4、5、6才是我们评估你项目参与度的重中之重

恭喜你即将完成一个完整的深度学习项目!🎊 从数据处理到模型构建,从训练优化到性能评估,你将体验到AI工程师的完整工作流程。

这个情感分析系统不仅仅是一个作业项目,它代表着你在AI道路上的重要里程碑。通过这个项目,你将:

  • 🧠 深度理解RNN/LSTM的工作原理
  • 💻 掌握实战深度学习项目的完整流程
  • 🚀 体验创新模型优化的乐趣
  • 📊 学会评估AI系统的性能

记住,每一行你亲手写下的代码,每一个你独立解决的问题,都在为你的AI工程师之路打下坚实的基础。

相信自己,你正在创造智能! 🌟✨

加油,未来的AI专家!你的情感分析系统将会是你AI之路上的第一个杰作!🎨🤖


本题分为”数据处理 / 模型实现 / 训练 / 优化”四部分,AI 使用政策:

部分政策
数据预处理允许用 AI 辅助,但必须理解每个函数的作用
模型实现(BiLSTM)禁止让 AI 直接生成完整模型,可以先写伪代码再实现
性能优化允许自由使用AI,探索更优方案
报告必须用自己的话,错误分析必须基于真实实验结果

提交时在报告末尾标注

  • 哪些部分借助了 AI
  • AI 的回答是否正确,你做了哪些修改

  • 代码文件ml-7-姓名-学号.ipynb(推荐,方便展示训练过程)或 ml-7-姓名-学号.py
  • 实验报告ml-7-姓名-学号.md
  • 将两个文件压缩为一个 zip
  • 邮箱: glimmerml401@163.com

  • 主题: 7-姓名-学号

出题人:出题人头像  Emily

QQ:3629542766

邮箱:3629542766@qq.com