文章目录
- 项目背景:让AI理解文字背后的情绪
- 技术选型:为什么是RNN而不是Transformer?
- 架构设计:从文本到情感的流水线
- 核心实现:一步步搭建情感分析模型
- 1. 环境与数据准备
- 2. 构建词表与数据迭代器
- 3. 定义LSTM模型
- 4. 训练与评估循环
- 踩坑记录:那些年我遇到的RNN“坑”
- 效果对比与项目总结
项目背景:让AI理解文字背后的情绪
在之前的文章中,我们处理的大多是静态的、位置无关的数据,比如图像分类。但人类语言是序列化的,一个词的含义往往依赖于它前后的语境。比如“这个手机真是‘炸’了”和“这个手机电池‘炸’了”,前者是正向情绪,后者是负面新闻。要处理这类问题,就需要能够“记住”上下文的模型,这就是循环神经网络(RNN)的主场。
最近,我需要快速评估一批用户评论的情感倾向,手动看效率太低,用传统的基于词典的方法(比如给“好”+1分,“差”-1分)准确率堪忧,因为它无法理解“好得不像话”和“好什么好”的天壤之别。于是,我决定动手搭建一个基于RNN的情感分析模型,目标是输入一段评论文本,输出“正面”或“负面”的情感标签。这是一个经典的序列分类任务,非常适合作为RNN的入门实战项目。
技术选型:为什么是RNN而不是Transformer?
对于文本情感分析这个任务,可选的模型很多:
- 传统机器学习:如TF-IDF特征 + SVM。对于简单场景有效,但难以捕捉深层语义和上下文。
- CNN:可以捕捉局部短语特征,但本质上仍是局部窗口操作,对长距离依赖建模能力较弱。
- RNN/LSTM/GRU:天然为序列设计,能较好地建模上下文信息,是处理变长序列的经典选择。
- Transformer/BERT:当前SOTA,基于自注意力机制,并行能力强,对长距离依赖建模极佳。
作为一个实战入门项目,我选择了RNN的变体——LSTM。原因有三:第一,Transformer/BERT虽然强大,但模型复杂、训练资源要求高,不利于快速理解和上手核心的序列建模思想;第二,LSTM作为RNN的改进,有效缓解了原始RNN的梯度消失/爆炸问题,是学习序列模型的必经之路;第三,对于这个中等复杂度的任务,LSTM完全能够达到不错的性能,且训练速度快。我们的技术栈是PyTorch,因为它动态图机制对RNN这类模型非常友好,调试直观。
架构设计:从文本到情感的流水线
整个项目的流程可以看作一个数据处理流水线,核心架构如下图所示(此处为文字描述):
原始文本 -> 分词与清洗 -> 构建词表 -> 文本转索引序列 -> 嵌入层 -> LSTM层 -> 全连接分类层 -> 情感标签- 数据预处理模块:负责将原始字符串转化为模型可读的数字张量。包括分词、建立词表(Vocabulary)、将词映射为索引(Index),以及处理变长序列的填充(Padding)。
- 模型模块:核心是一个
Embedding + LSTM + Classifier的结构。Embedding层:将每个词的索引转换为一个稠密的词向量,这是模型学习语义的基础。LSTM层:接收词向量序列,逐步处理并更新其隐藏状态,最终输出包含了整个序列信息的上下文表示。分类层:通常取LSTM最后一个时间步的隐藏状态,或者所有时间步隐藏状态的平均/最大值,通过一个全连接网络映射到情感类别。
- 训练与评估模块:定义损失函数(如交叉熵损失)和优化器(如Adam),进行模型训练和性能评估。
核心实现:一步步搭建情感分析模型
1. 环境与数据准备
我们使用PyTorch和Torchtext来简化文本数据处理。数据集选用经典的IMDb电影评论数据集,它包含5万条标注好“正面/负面”的评论。
importtorchimporttorch.nnasnnimporttorch.optimasoptimfromtorchtext.legacyimportdata,datasets# 设置随机种子,保证可复现性SEED=1234torch.manual_seed(SEED)# 定义字段(如何预处理文本和标签)TEXT=data.Field(tokenize='spacy',# 使用spacy分词器tokenizer_language='en_core_web_sm',include_lengths=True)# 包含文本实际长度,用于处理变长序列LABEL=data.LabelField(dtype=torch.float)# 加载IMDb数据集,并自动划分为train/testtrain_data,test_data=datasets.IMDB.splits(TEXT,LABEL)# 查看示例print(f'训练集样本数:{len(train_data)}')print(f'测试集样本数:{len(test_data)}')print(vars(train_data.examples[0]))# 查看第一条数据2. 构建词表与数据迭代器
词表是单词到索引的映射。我们只用训练集来构建词表,并限制词表大小以控制模型复杂度。
# 构建词表,只保留最高频的25000个词,其余用<unk>表示MAX_VOCAB_SIZE=25000TEXT.build_vocab(train_data,max_size=MAX_VOCAB_SIZE)LABEL.build_vocab(train_data)# 查看词表信息print(f"词表大小:{len(TEXT.vocab)}")print(f"最常见的10个词:{TEXT.vocab.freqs.most_common(10)}")print(f"<unk>对应的索引:{TEXT.vocab.stoi[TEXT.unk_token]}")# 创建数据迭代器(DataLoader),自动进行批处理、填充和排序(优化RNN效率)BATCH_SIZE=64device=torch.device('cuda'iftorch.cuda.is_available()else'cpu')train_iterator,test_iterator=data.BucketIterator.splits((train_data,test_data),batch_size=BATCH_SIZE,sort_within_batch=True,# 为使用pack_padded_sequence,需要按长度排序device=device)3. 定义LSTM模型
这是最核心的部分。注意,我们使用nn.utils.rnn.pack_padded_sequence来让LSTM只处理有效长度,避免填充符<pad>影响模型。
classRNN(nn.Module):def__init__(self,vocab_size,embedding_dim,hidden_dim,output_dim,n_layers,bidirectional,dropout):super().__init__()# 嵌入层self.embedding=nn.Embedding(vocab_size,embedding_dim)# LSTM层self.rnn=nn.LSTM(embedding_dim,hidden_dim,num_layers=n_layers,bidirectional=bidirectional,dropout=dropoutifn_layers>1else0,# 只有多层时才在层间加dropoutbatch_first=True)# 输入输出张量形状为 [batch, seq_len, features]# 全连接分类层# 如果是双向LSTM,隐藏状态维度要乘以2self.fc=nn.Linear(hidden_dim*2ifbidirectionalelsehidden_dim,output_dim)self.dropout=nn.Dropout(dropout)defforward(self,text,text_lengths):# text shape: [batch size, sent len]# text_lengths shape: [batch size]embedded=self.dropout(self.embedding(text))# [batch size, sent len, emb dim]# 打包序列,避免填充部分参与计算packed_embedded=nn.utils.rnn.pack_padded_sequence(embedded,text_lengths.cpu(),batch_first=True,enforce_sorted=False)packed_output,(hidden,cell)=self.rnn(packed_embedded)# 解包输出(后续如果要用所有时间步的输出时会用到)# output, output_lengths = nn.utils.rnn.pad_packed_sequence(packed_output, batch_first=True)# 处理双向LSTM的最终隐藏状态# hidden shape: [num_layers * num_directions, batch size, hid dim]ifself.rnn.bidirectional:hidden=self.dropout(torch.cat((hidden[-2,:,:],hidden[-1,:,:]),dim=1))# 连接最后两个方向的隐藏状态else:hidden=self.dropout(hidden[-1,:,:])# 取最后一层的隐藏状态# hidden shape: [batch size, hid dim * num_directions]returnself.fc(hidden)# 初始化模型INPUT_DIM=len(TEXT.vocab)EMBEDDING_DIM=300HIDDEN_DIM=256OUTPUT_DIM=1# 二分类,输出一个标量,用sigmoid激活N_LAYERS=2BIDIRECTIONAL=True# 使用双向LSTM,能同时看到前后文信息DROPOUT=0.5model=RNN(INPUT_DIM,EMBEDDING_DIM,HIDDEN_DIM,OUTPUT_DIM,N_LAYERS,BIDIRECTIONAL,DROPOUT)model=model.to(device)4. 训练与评估循环
训练时需要注意,我们从BucketIterator得到的数据batch.text是一个元组,第一个元素是文本索引,第二个元素是每个句子的实际长度。
# 定义优化器和损失函数optimizer=optim.Adam(model.parameters())criterion=nn.BCEWithLogitsLoss()# 二分类交叉熵损失,内部包含了sigmoiddefbinary_accuracy(preds,y):# 计算准确率rounded_preds=torch.round(torch.sigmoid(preds))correct=(rounded_preds==y).float()acc=correct.sum()/len(correct)returnaccdeftrain(model,iterator,optimizer,criterion):epoch_loss=0epoch_acc=0model.train()forbatchiniterator:text,text_lengths=batch.text# 解包出文本和长度optimizer.zero_grad()predictions=model(text,text_lengths).squeeze(1)# 去掉多余的维度loss=criterion(predictions,batch.label)acc=binary_accuracy(predictions,batch.label)loss.backward()optimizer.step()epoch_loss+=loss.item()epoch_acc+=acc.item()returnepoch_loss/len(iterator),epoch_acc/len(iterator)defevaluate(model,iterator,criterion):epoch_loss=0epoch_acc=0model.eval()withtorch.no_grad():forbatchiniterator:text,text_lengths=batch.text predictions=model(text,text_lengths).squeeze(1)loss=criterion(predictions,batch.label)acc=binary_accuracy(predictions,batch.label)epoch_loss+=loss.item()epoch_acc+=acc.item()returnepoch_loss/len(iterator),epoch_acc/len(iterator)# 开始训练N_EPOCHS=5forepochinrange(N_EPOCHS):train_loss,train_acc=train(model,train_iterator,optimizer,criterion)valid_loss,valid_acc=evaluate(model,test_iterator,criterion)print(f'Epoch:{epoch+1:02}')print(f'\tTrain Loss:{train_loss:.3f}| Train Acc:{train_acc*100:.2f}%')print(f'\t Val. Loss:{valid_loss:.3f}| Val. Acc:{valid_acc*100:.2f}%')踩坑记录:那些年我遇到的RNN“坑”
- 忘记处理变长序列:最开始的版本,我直接把填充后的等长序列扔给LSTM,结果模型性能很差。因为填充符
<pad>没有实际意义,却参与了LSTM的状态计算,严重干扰了模型。解决方案:一定要使用pack_padded_sequence和pad_packed_sequence这对“黄金搭档”。 - 隐藏状态使用错误:双向LSTM的最终隐藏状态
hidden是一个多层、双向的堆叠张量。我最初错误地只取了hidden[-1,:,:],丢失了反向信息。解决方案:对于双向LSTM,需要将最后时间步的正向和反向隐藏状态连接起来:torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim=1)。 - 过拟合严重:在小型数据集上,LSTM很容易过拟合。表现为训练集准确率很高,但验证集上不去。解决方案:果断加入
Dropout,无论是在嵌入层后还是LSTM层间(多层时)。将Dropout率设为0.5是个不错的起点。 - 梯度爆炸:在调试更深的RNN或使用
tanh激活时遇到过。解决方案:使用梯度裁剪(torch.nn.utils.clip_grad_norm_),这是一个稳定RNN训练的常用技巧。
效果对比与项目总结
经过5个epoch的训练,我们的双向LSTM模型在IMDb测试集上的准确率大约能达到87%-89%。这相比简单的词袋模型有质的飞跃。我们可以用训练好的模型做个快速推理:
importspacy nlp=spacy.load('en_core_web_sm')defpredict_sentiment(model,sentence):model.eval()# 分词并转为小写tokenized=[tok.text.lower()fortokinnlp.tokenizer(sentence)]# 将词转换为索引indexed=[TEXT.vocab.stoi[t]fortintokenized]length_tensor=torch.LongTensor([len(indexed)])tensor=torch.LongTensor(indexed).unsqueeze(1).to(device)# 添加batch维度prediction=torch.sigmoid(model(tensor,length_tensor))returnprediction.item()# 测试test_sentences=["This film is terrible and boring.","What a fantastic movie with brilliant performances!","It's not bad, but I've seen better."]forsentintest_sentences:prob=predict_sentiment(model,sent)print(f'Review:{sent}')print(f'Sentiment:{"Positive"ifprob>0.5else"Negative"}(Confidence:{prob:.4f})\n')通过这个项目,我们完整地走通了使用RNN(LSTM)进行文本分类的流程:从数据预处理、词表构建、模型搭建、训练到推理。虽然Transformer如今风头正劲,但理解LSTM的工作机制,仍然是掌握序列建模的坚实基础。它让你真正理解模型是如何“记住”和“理解”上下文的。
项目扩展思考:
- 尝试使用预训练词向量(如GloVe)初始化嵌入层,可以进一步提升模型性能,尤其是在训练数据不足时。
- 将LSTM替换为GRU,比较两者在性能和训练速度上的差异。
- 尝试使用CNN或CNN+LSTM的混合架构,捕捉局部特征与序列依赖。
如有问题欢迎评论区交流,持续更新中…