7:循环神经网络实战项目
恭喜你!🎉 在经过上一个题目的深入学习后,你已经掌握了循环神经网络的核心知识。现在是时候将理论付诸实践,构建一个真正能够理解人类情感的 AI 系统了!
这个项目将带你从理论走向应用,体验 AI 工程师的真实工作流程——从数据处理到模型评估
Part 0:从理论到实战(预热)
Section titled “Part 0:从理论到实战(预热)”在第 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)
Part 1:数据集
Section titled “Part 1:数据集”主数据集:IMDB 50K Movie Reviews
Section titled “主数据集:IMDB 50K Movie Reviews”下载:数据文件请到 QQ 群下载 IMDB Dataset 文件夹。
数据格式(IMDB Dataset.csv):
| review | sentiment |
|---|---|
| ”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 reimport numpy as npfrom collections import Counterfrom torch.utils.data import Dataset, DataLoaderfrom 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 会让真实内容靠后,更接近最后一步)
2.2 SentimentDataset
Section titled “2.2 SentimentDataset”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): pass2.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: 你的实现 pass2.4 评估工具(已提供,直接使用)
Section titled “2.4 评估工具(已提供,直接使用)”我们提供 calculate_metrics 和 plot_confusion_matrix 函数,你不需要自己实现,直接调用即可。
"""别忘了在环境里装好 sklearn和seaborn"""from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matriximport matplotlib.pyplot as pltimport 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()Part 3:双向 LSTM 模型
Section titled “Part 3:双向 LSTM 模型”3.1 实现 BiLSTMClassifier
Section titled “3.1 实现 BiLSTMClassifier”import torchimport 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] # 怎么取出最后一层的前向和后向隐藏状态并拼接? pass3.2 [选做] 对比实验
Section titled “3.2 [选做] 对比实验”实现以下变体并对比:
- 单向 LSTM vs 双向 LSTM
- 1 层 vs 2 层 vs 3 层
- Max-pooling vs Mean-pooling vs 最后时间步
- LSTM vs GRU
Part 4:训练与评估
Section titled “Part 4:训练与评估”4.1 实现 SentimentTrainer
Section titled “4.1 实现 SentimentTrainer”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. [选做] 保存最佳模型 pass4.2 主函数
Section titled “4.2 主函数”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 # 绘制混淆矩阵和训练曲线Part 5:性能优化挑战
Section titled “Part 5:性能优化挑战”基础模型达到 85% 后,尝试以下优化:
5.1 模型层面
Section titled “5.1 模型层面”- 调整 hidden_dim / num_layers / dropout
- 用 GRU 替代 LSTM,比较速度和精度
- 加 Attention 层(选做,为第 8 题 Transformer 预热)
5.2 数据层面
Section titled “5.2 数据层面”- 用预训练词向量(Word2Vec / GloVe)替代随机初始化 Embedding (提示:torchtext 或 gensim 可以加载预训练向量)
- 文本增强:随机删除/替换词
5.3 训练层面
Section titled “5.3 训练层面”- 学习率调度:ReduceLROnPlateau / CosineAnnealingLR
- 权重衰减:optimizer 加 weight_decay=1e-5
- 梯度裁剪:torch.nn.utils.clip_grad_norm_(防止梯度爆炸)
Part 6:深度思考
Section titled “Part 6:深度思考”完成项目后,回答以下问题(每题 100-200 字):
Q1:你的模型在哪些样本上容易判断错误?分析错误样本的特征(如讽刺、否定词、长评论等)。
Q2:双向 LSTM 比单向 LSTM 好在哪?用你的实验数据说明(对比两者的 test accuracy)。 (呼应第 6 题 Q5:双向 LSTM 在什么场景不能用?)
Q3:如果让你用这个模型做”实时评论审核”(用户提交评论后立即判断),你会选单向还是双向 LSTM?为什么?
报告结构(仅参考)
Section titled “报告结构(仅参考)”- 数据探索(数据量、类别分布、文本长度分布)
- 预处理方案(清洗、分词、词汇表、填充策略)
- 模型结构(BiLSTM 架构图、超参数选择理由)
- 训练过程(训练曲线、调参历程)
- 评估结果(accuracy/precision/recall/f1、混淆矩阵、错误分析)
- 改进策略(尝试了什么、效果如何)
- 深度思考(Part 6 的 3 个问题)
- 收获与反思
- AI 使用说明
报告提示:用你自己的语言来描述这个项目!分享你的思考过程、遇到的困难以及解决问题时的成就感。过程>>结果, 4、5、6才是我们评估你项目参与度的重中之重
🌟 项目寄语
Section titled “🌟 项目寄语”恭喜你即将完成一个完整的深度学习项目!🎊 从数据处理到模型构建,从训练优化到性能评估,你将体验到AI工程师的完整工作流程。
这个情感分析系统不仅仅是一个作业项目,它代表着你在AI道路上的重要里程碑。通过这个项目,你将:
- 🧠 深度理解RNN/LSTM的工作原理
- 💻 掌握实战深度学习项目的完整流程
- 🚀 体验创新模型优化的乐趣
- 📊 学会评估AI系统的性能
记住,每一行你亲手写下的代码,每一个你独立解决的问题,都在为你的AI工程师之路打下坚实的基础。
相信自己,你正在创造智能! 🌟✨
加油,未来的AI专家!你的情感分析系统将会是你AI之路上的第一个杰作!🎨🤖
关于 AI 使用
Section titled “关于 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