简介:一套基于长短期记忆网络(LSTM)的中文文本情感分析毕业设计项目,完整覆盖数据预处理、jieba分词、模型训练、评估与预测流程。数据集包含积极和消极中文文本各约八千条,代码已测试通过,直接运行即可复现训练结果。项目同时提供预训练模型、词向量、环境配置说明、效果预览图等,既适合计算机专业学生完成毕业设计或课程设计,也可作为教师授课和企业员工学习深度学习与自然语言处理的参考。压缩包共十一个文件,涵盖程序脚本、模型权重、词向量、配置文件与说明文档,整体约6.63MB,便于下载后快速对照学习。目前已有七十九人浏览学习,完整展示了从jieba分词到LSTM搭建的可执行思路,并在README中注明使用方式与注意事项,适合希望快速上手中文情感分析、搭建文本分类模型的学习者。
1. 打开这份LSTM情感分析项目之前,先想清楚你要的是什么
一份号称“高分毕业设计”的LSTM文本情感分析项目,正负面各8000条数据,有代码、文档、模型、数据集,这几乎是本领域最标准的选题配置。很多人在拿到这类项目后的第一反应是直接跑通、截图、写报告,但真正让答辩老师认可的,从来不是“模型能跑”,而是“你清楚每一层在干什么,数据为什么这样处理,失败的时候怎么定位”。本文会在把LSTM文本情感分析这条技术链路拆开讲透的前提下,给你一份可以直接照着复现的完整方案,包含数据预处理、模型搭建、训练调参、避坑记录和进阶验证,既适合拿来做毕业设计主体,也适合作为情感分析方向的第一份动手实践。我默认你至少装好了Python 3.8以上版本,并且会用pip安装依赖包;如果还没准备好环境,顺手把numpy、pandas、torch装上即可,具体版本要求我会在对应小节说明。
2. 基本原理与数据准备:为什么是LSTM,以及正负面各8000条怎么落到磁盘
2.1 LSTM建模文本情感的基本逻辑:从词向量到隐状态
文本情感分析本质上是一个文本分类任务,输入是一段文本,输出是正面或负面。做这个任务最早期的方案是词袋模型加朴素贝叶斯或SVM,靠统计词频来判断情感倾向。这类方法的局限很明显:它丢掉了词语之间的顺序关系,遇到“不好看”和“看不不好”这种语义反转或语序敏感的表达,很容易翻车。循环神经网络(RNN)的引入解决了“按顺序读词”的问题,而LSTM是RNN的一个变体,专门用来缓解长序列训练中的梯度消失和梯度爆炸。
LSTM的核心是一个记忆单元和三个门:遗忘门决定上一时刻的细胞状态保留多少,输入门决定新信息写入多少,输出门决定当前隐状态输出多少。每一步读入一个词向量,结合上一步的隐状态和细胞状态,更新记忆,最终把整个句子的信息压缩到最后一步的隐状态里。用这个隐状态接一个全连接层做二分类,就是最经典的LSTM文本情感分析模型结构。
这里需要先明确一个观点,数据规模只有正负面各8000条,属于典型的小数据集。LSTM在这种规模下不会展现出碾压BERT之类的优势,但它的优势在于结构简单、训练快、可解释性强,而且代码量少,适合在毕业设计里把“原理—实现—实验—分析”讲完整。如果你在答辩中被问“为什么不用BERT”,一个诚实的回答是:BERT在这个数据规模下容易过拟合,且训练成本高,LSTM已经能达到可接受的准确率,同时能清晰展示序列建模的过程。这个回答比吹嘘LSTM全面优于Transformer更让人信服。
2.2 构建正负面各8000条的数据集:采集、清洗与标注
正负面各8000条听起来数据量不大,但你要考虑的是这16000条文本是怎么来的、质量是否可靠。常见的数据来源有三种:公开情感数据集(如中文酒店评论、电商评论)、爬取社交媒体评论后人工标注、用现成的评论数据做二次清洗。无论你用哪种方式,最终落在磁盘上的格式都应该是一致的。我一般会把它整理成CSV文件,两列,一列是text,一列是label,label用1表示正面、0表示负面。这样做的好处是pandas读取后不需要额外解析,文档里描述起来也直观。
数据清洗是这个环节里最耗时也最容易忽略的步骤。原始评论里通常夹杂着HTML标签、URL、@用户、多余的空格和换行、全角半角混乱、表情符号等问题。清洗的目标不是把文本变得越短越好,而是去掉对情感判断没有帮助的噪音。以下是我常用的清洗函数,直接可用在数据集构建阶段:
import re import pandas as pd def clean_text(text: str) -> str: # 去掉HTML标签 text = re.sub(r'<[^>]+>', '', text) # 去掉URL text = re.sub(r'http[s]?://\S+|www\.\S+', '', text) # 去掉@用户和话题标签 text = re.sub(r'@\S+|#\S+#', '', text) # 统一全角半角:全角字符转半角 text = ''.join( chr(ord(c) - 0xFEE0) if ord(c) > 0xFF00 and ord(c) < 0xFF5F else c for c in text ) # 去掉多余空白 text = re.sub(r'\s+', ' ', text).strip() return text df = pd.read_csv('raw_reviews.csv') df['text'] = df['text'].apply(clean_text) # 去除清洗后为空的行 df = df[df['text'].str.len() > 0] # 按label分层打乱,保证正负样本在训练测试中的比例一致 df = df.groupby('label', group_keys=False).apply(lambda x: x.sample(frac=1, random_state=42)) df.to_csv('dataset.csv', index=False, encoding='utf-8-sig')逻辑说明:re.sub(r'<[^>]+>', '', text)是正则去HTML标签的通用写法;全角转半角的循环逻辑利用了Unicode码位偏移规律,只处理0xFF00到0xFF5F范围内的字符;df.groupby('label', group_keys=False).apply(...)是按类别分组后再随机打乱,目的是防止原始数据里正负样本顺序集中导致训练时类别分布失衡。参数random_state=42是为了复现打乱顺序,答辩时如果你被要求重新跑一遍,结果一致会让实验更有说服力。
清洗之后另一个关键步骤是检查重复样本和矛盾标注。文本情感数据里常见的情况是同一条评论出现多次,或者内容接近但标注相反的样本。重复样本不处理的话,训练集和测试集之间可能出现数据泄漏,得到的准确率虚高。处理方式很简单:对text列做去重,保留每条文本第一次出现的记录。
去重后如果样本不足8000条,优先补充数据,而不是靠复制已有样本来凑数。复制样本会让模型学到的分布失真,答辩时一旦被追问训练集和测试集的构造方式,这个问题很难解释。2.3 数据预处理:分词、构建词表、生成训练样本
数据清洗完只是第一步,模型不能直接吃字符串,需要把文本转成数值。中文文本预处理的常见做法是先分词,再基于分词结果构建词表,把每个词映射成一个整数ID,最后把一条文本转成一个整数序列。
分词工具有很多选择,jieb、pkuseg、THULAC都能用。考虑到毕业设计场景下的复现稳定性和安装便利性,我推荐使用jieba,它安装简单、纯Python实现、无需额外模型文件。下面给出一段完整的分词加词表构建代码:
import jieba import json from collections import Counter def tokenize(text: str) -> list: # 精确模式分词,适合情感分析场景 return list(jieba.cut(text.strip())) # 读取已清洗的数据 df = pd.read_csv('dataset.csv') # 全部分词,统计词频 all_tokens = [] for text in df['text'].tolist(): all_tokens.extend(tokenize(text)) counter = Counter(all_tokens) # 保留出现次数>=2的词,过滤低频噪音 min_freq = 2 vocab = {word for word, freq in counter.items() if freq >= min_freq} # 构建词表映射:PAD=0, UNK=1, 其余从2开始 word2idx = {'<PAD>': 0, '<UNK>': 1} for word in vocab: word2idx[word] = len(word2idx) with open('word2idx.json', 'w', encoding='utf-8') as f: json.dump(word2idx, f, ensure_ascii=False) # 文本转ID序列,统一截断或填充到固定长度 MAX_LEN = 100 def encode_text(text: str) -> list: tokens = tokenize(text) ids = [word2idx.get(w, word2idx['<UNK>']) for w in tokens] if len(ids) > MAX_LEN: ids = ids[:MAX_LEN] else: ids = ids + [word2idx['<PAD>']] * (MAX_LEN - len(ids)) return ids df['input_ids'] = df['text'].apply(encode_text) df.to_pickle('encoded_data.pkl')参数说明:min_freq=2的意思是只保留在全部语料中出现过至少2次的词,出现1次的词统一映射为<UNK>,这样做的理由是低频词往往是错别字、专有名词或噪音,保留它们会让模型去记忆不具泛化性的模式。MAX_LEN=100是序列长度上限,文本超过100个词就截断,不足就补<PAD>。这里100是一个经验值,你需要先统计一下数据集中文本长度的分布,如果90%的文本都少于80个词,那100是安全的;如果你的数据源里有大量长评,可能需要调到200或更高。
词表构建好之后,检查一下word2idx的大小。常见情况是词表规模在1万到3万之间。如果你发现词表超过5万,说明min_freq设得太低;如果词表不到5000,说明min_freq应该调低或者数据集本身就偏小。词表规模直接影响Embedding层的参数数量,词表越大模型越大,训练越慢。
编码完成后的数据我是用to_pickle保存的,原因是df.to_csv会把列表形式的input_ids序列化成字符串,下次读取还要再解析,而pickle能完整保留DataFrame里的Python对象结构,省去格式转换的麻烦。缺点是文件不可直接打开查看,但这在毕设场景里不算问题。
3. 用PyTorch实现LSTM情感分类模型:搭建、训练、评估的全流程
3.1 模型结构设计:Embedding + LSTM + 全连接的标准组合
数据准备好之后进入模型实现环节。我采用PyTorch实现,理由一是它在学术和工程场景都是主流,答辩时不容易被质疑;二是代码结构清晰,forward过程很好解释。模型结构选用经典的Embedding层加单层LSTM加全连接输出层。Embedding层把词ID映射成稠密向量,LSTM层负责按顺序编码序列信息,全连接层把LSTM最后一步的隐状态压缩成两个类别的得分。
用PyTorch的nn.Module定义模型,完整代码如下:
import torch import torch.nn as nn class LSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_layers, num_classes=2, dropout=0.5): super(LSTMClassifier, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM( embedding_dim, hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=False, dropout=dropout if num_layers > 1 else 0.0 ) self.fc = nn.Linear(hidden_dim, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, input_ids): emb = self.embedding(input_ids) # [batch, seq_len, embedding_dim] lstm_out, (h_n, c_n) = self.lstm(emb) # lstm_out: [batch, seq_len, hidden_dim] # 取最后一层的最终隐状态 h_n[-1],形状 [batch, hidden_dim] last_hidden = h_n[-1] out = self.dropout(last_hidden) logits = self.fc(out) # [batch, num_classes] return logits逻辑说明:nn.Embedding的第一个参数是词表大小,第二个参数是嵌入维度;padding_idx=0告诉Embedding层ID为0的位置不更新梯度,始终为全零向量,避免填充位引入无意义的信息。LSTM层设置batch_first=True后输入形状为[batch_size, seq_len, embedding_dim],h_n的形状是[num_layers, batch_size, hidden_dim],取h_n[-1]得到最后一层的隐状态。dropout只应用在全连接层前,这是LSTM加全连接结构里常见的做法。
参数设置需要重点解释:embedding_dim一般取100或200,太小表示能力不足,太大在小数据集上容易过拟合;hidden_dim取128是一个折中值,计算量适中且表达力足够;num_layers取1,单层LSTM在16000条样本的规模上已经足够捕获序列依赖,层数加深不一定提升效果,还会显著增加训练时间。如果你坚持用两层LSTM,dropout参数必须大于0,否则两层之间没有正则化,训练集准确率会虚高而测试集表现很差。
3.2 数据加载:用DataLoader批量喂给模型
定义好模型之后,需要把之前保存的编码数据加载成PyTorch的Dataset和DataLoader。注意encoded_data.pkl里存的input_ids是Python列表,需要转成torch.LongTensor。代码如下:
from torch.utils.data import Dataset, DataLoader from sklearn.model_selection import train_test_split class SentimentDataset(Dataset): def __init__(self, df): self.input_ids = df['input_ids'].tolist() self.labels = df['label'].tolist() def __len__(self): return len(self.labels) def __getitem__(self, idx): input_ids = torch.LongTensor(self.input_ids[idx]) label = torch.LongTensor([self.labels[idx]]) return input_ids, label # 数据划分:训练集 80%,测试集 20% df = pd.read_pickle('encoded_data.pkl') train_df, test_df = train_test_split(df, test_size=0.2, stratify=df['label'], random_state=42) train_dataset = SentimentDataset(train_df) test_dataset = SentimentDataset(test_df) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)参数说明:stratify=df['label']是分层抽样,保证训练集和测试集中正负样本比例都和原始数据一致,这个参数在分类任务里必须设置,否则随机划分可能导致某一类别在测试集里占比异常,评估指标失真。batch_size=64是一个偏保守的设置,如果你的显存或内存充足,调到128能加快训练;如果训练时频繁内存溢出,降到32。shuffle=True只用在训练集,让每个epoch里样本顺序不同,避免模型学到样本顺序带来的虚假模式;测试集不需要打乱,保持顺序方便后续分析错误样本。
3.3 训练循环:损失函数、优化器与评估指标
模型和数据都准备好之后,进入训练环节。情感二分类任务默认使用交叉熵损失函数,优化器选择Adam,学习率从1e-3开始试。训练循环的完整代码如下:
import torch.optim as optim from sklearn.metrics import accuracy_score, f1_score device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = LSTMClassifier( vocab_size=len(word2idx), embedding_dim=100, hidden_dim=128, num_layers=1, num_classes=2, dropout=0.5 ).to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) num_epochs = 20 for epoch in range(num_epochs): model.train() total_loss = 0.0 for input_ids, labels in train_loader: input_ids = input_ids.to(device) labels = labels.squeeze(1).to(device) optimizer.zero_grad() logits = model(input_ids) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() # 每个epoch结束在测试集上评估,方便观察是否过拟合 model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for input_ids, labels in test_loader: input_ids = input_ids.to(device) logits = model(input_ids) preds = torch.argmax(logits, dim=1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.squeeze(1).cpu().numpy()) acc = accuracy_score(all_labels, all_preds) f1 = f1_score(all_labels, all_preds) print(f'Epoch {epoch+1}/{num_epochs}, Loss: {total_loss/len(train_loader):.4f}, ' f'Test Acc: {acc:.4f}, F1: {f1:.4f}')这段代码有几点需要说明。第一,labels.squeeze(1)是因为__getitem__返回的label形状是[1],不压缩的话CrossEntropyLoss会报维度不匹配。第二,每个epoch结束后都在测试集上做一次完整评估,这个习惯非常重要,它能让你看到训练过程中测试准确率的变化曲线,判断模型是欠拟合还是过拟合。第三,评估时要写torch.no_grad(),否则PyTorch会为推理过程构建计算图,白白浪费内存和时间。
3.4 训练效果的判断:损失下降不代表模型好
训练跑完之后,不要只看最后一个epoch的准确率。把每个epoch的损失和准确率打印出来观察趋势:如果训练损失持续下降但测试准确率在第8个epoch开始不升反降,说明模型开始过拟合;如果训练损失在头几个epoch就降得很慢,大概率是学习率偏大或数据预处理环节有bug。
一个很常见的现象是第一个epoch结束测试准确率就已经到了90%以上,这时候不要高兴太早。你需要检查一下是不是数据泄漏了:训练集和测试集里是否有重复文本、是否用了同一批样本做过数据增强、词汇表的构建是否在划分数据之前用了全量数据。如果是后者,词表本身并不算泄漏,但如果你的统计特征来自全量数据,模型会从测试集的分布里获取信息。
训练好的模型要保存下来,后续预测和答辩演示都需要用到:
torch.save(model.state_dict(), 'lstm_sentiment_model.pth')同时建议把词表word2idx.json放在同一个目录里。模型文件只存了参数权重,不包含词表,恢复模型时必须先加载词表才能正确编码输入文本。这个问题在答辩演示时经常出问题,换个机器跑起来报错,十有八九是忘了拷贝词表文件。
4. 避坑指南:LSTM情感分析项目的6个典型翻车现场
4.1 样本不均衡引起的“虚假高分”
现象:训练完成后测试集准确率高达97%,但你看一下分类报告,发现正样本的召回率和F1都很低,模型几乎把所有样本都预测成负面。
原因:正负面各8000条虽然在总量上均衡,但如果你在2.2节清洗数据时没有按类别检查清洗后的数量,可能出现负面评论更容易清洗、正面评论被误删的情况。更常见的原因是训练时没有分层抽样,训练集里正面样本远少于负面样本。
解决:在2.3节划分数据集时强制使用stratify=df['label'],并打印train_df['label'].value_counts()确认分布。如果实际数据本身就不均衡,可以考虑在CrossEntropyLoss里设置class_weight参数,给少数类更高的权重。用混淆矩阵替代准确率作为主要评估指标。
4.2 文本长度截断导致关键信息丢失
现象:测试集上某些短文本预测正确,但一旦评论长度超过MAX_LEN,预测结果明显变差。检查被截断的样本,发现情感关键词正好出现在截断位置之后。
原因:MAX_LEN=100是从整体分布统计出来的,但你没有看被截断样本的比例。如果截断样本占了总样本的20%,那说明100这个值偏小。更隐蔽的问题是,截断策略是直接从头截100个词,尾部的信息全部丢弃。
解决:先统计文本长度分布,df['text'].str.len().describe(),把MAX_LEN设为90分位数对应的长度,而不是拍脑袋定100。如果某些样本实在太长,也可以采用头尾各保留一部分的策略,但这会引入更多代码复杂度,我建议优先调大MAX_LEN。
4.3 训练损失不下降:梯度爆炸或学习率过大
现象:训练损失在几个epoch后变成NaN,或者Loss值忽大忽小完全没规律,模型完全学不到东西。
原因:LSTM虽然比原生RNN抗梯度消失,但输入数据没有归一化或者学习率过大,依然可能触发梯度爆炸。更常见的原因是Embedding层初始化产生的梯度过大,在训练初期直接让权重崩掉。
解决:把学习率从1e-3降到1e-4重新试;在模型训练循环里加梯度裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0),限制梯度的最大范数,这是LSTM训练的标准操作。如果你用了预训练词向量,检查词向量加载后是否需要缩放,默认的均匀分布初始化在数据量小时反而是最安全的。
4.4 过拟合:训练集准确率接近100%,测试集只有75%
现象:训练集的损失随着epoch增加一路下降直到接近0,但测试集准确率在第5个epoch之后就开始波动甚至下降。
原因:16000条数据对单层LSTM来说规模不大,模型的参数数量明显多于数据量,很容易记住训练数据里的噪音。dropout=0.5在全连接层前加了正则化,但LSTM层本身没有dropout,单层结构也不支持nn.LSTM内部启用dropout。
解决:把dropout从0.5提到0.7,全连接层前和输出层之间多一层nn.Linear(hidden_dim, hidden_dim)再接ReLU激活函数,让模型容量适度增加但正则化更强。或者改用早停法:保存每个epoch在测试集上准确率最高的模型状态,训练结束后加载那个最优权重,而不是用最后一个epoch的权重。早停的实现代码很简洁,用best_acc记录历史最优,达到最优时保存权重即可。
4.5 复现结果对不上:随机种子没固定
现象:同一个代码在答辩演示时跑出来的准确率和实验记录里的差了一大截,损失曲线也完全对不上。
原因:PyTorch、Python的random库、NumPy各自有独立的随机状态,我只设置了一个random_state但没固定所有随机源。DataLoader的shuffle、模型初始化、分词顺序都会引入随机性。
解决:在训练脚本最前面固定所有随机种子:
import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True注意,cudnn.deterministic = True会让CuDNN使用确定性算法,速度稍慢但结果可复现。这个设置是答辩前必须做的,否则实验结果无法复现会被视为学术不严谨。
4.6 中文文本分词引入的噪声
现象:模型在训练集上表现正常,但把训练好的模型拿去做实测时,对口语化文本、表情符号夹杂的评论预测效果很差。
原因:模型学到的词表是基于训练数据分词的,测试时遇到词表里没出现过的词全部映射为<UNK>。如果训练数据是规范的电商评论,而实测数据里有大量“哈哈哈”“yyds”这类网络热词,分词后这些词全部落入<UNK>,模型看到的信息量大幅减少。
解决:在构建词表时把min_freq=1纳入所有出现过的词,虽然会让词表变大、过拟合风险增加,但能缓解<UNK>过多的问题。另一个更推荐的做法是,在预测前对输入文本做一次emoji和网络用语映射,把“yyds”替换成“很好”,“绝绝子”替换成“喜欢”,这种简单规则能显著提升实际场景的预测效果,且不需要改动模型。
5. 让项目从“能跑”到“能答辩”的进阶验证与部署技巧
5.1 用训练好的模型预测单条新文本
训练和评估跑通之后,你的项目已经具备基本完整性。但答辩现场最常遇到的提问是“你现场预测几条数据给我看”。如果只靠训练代码里的测试循环,你无法方便地输入任意文本并得到结果。所以封装一个预测函数是必需的:
import json import jieba import torch def predict_sentiment(text: str, model, word2idx, max_len=100, device='cpu'): model.eval() # 与训练时相同的预处理流程 text = clean_text(text) tokens = list(jieba.cut(text.strip())) ids = [word2idx.get(w, word2idx['<UNK>']) for w in tokens] if len(ids) > max_len: ids = ids[:max_len] else: ids = ids + [word2idx['<PAD>']] * (max_len - len(ids)) input_tensor = torch.LongTensor([ids]).to(device) with torch.no_grad(): logits = model(input_tensor) prob = torch.softmax(logits, dim=1) pred = torch.argmax(logits, dim=1).item() return { 'label': '正面' if pred == 1 else '负面', 'positive_prob': round(prob[0][1].item(), 4), 'negative_prob': round(prob[0][0].item(), 4) } with open('word2idx.json', 'r', encoding='utf-8') as f: word2idx = json.load(f) model = LSTMClassifier(...) model.load_state_dict(torch.load('lstm_sentiment_model.pth', map_location='cpu')) print(predict_sentiment('这家餐厅的菜非常好吃,服务也很周到', model, word2idx))这里输出正负概率而不只是类别,是一个答辩加分点。你可以解释:模型先输出两个类别的得分,用softmax转换成概率分布,不仅告诉用户结果,还能给出置信度。比如一条样本预测为正面,但positive_prob只有0.55,说明模型不太确定;如果positive_prob达到0.98,说明特征非常明确。这样的细节能体现你对模型输出做过深入思考。
5.2 用注意力可视化解释模型为什么预测这个结果
LSTM最大的短板是黑匣子特性——输入一段文本,输出一个标签,中间发生了什么很难解释。答辩老师非常喜欢追问“模型凭什么认为这句话是负面的”。如果你答不上来,分数会打折扣。
常见的做法是加一个基于LSTM隐状态的注意力机制,在forward过程中计算每个时间步的隐状态对最终分类的贡献权重。把权重最大的几个词打印出来,就能看到模型重点关注的词是什么。这一节我强烈建议实现,代码只需要在原有模型上做小幅修改:
class AttentionLSTMClassifier(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, num_classes=2, dropout=0.5): super(AttentionLSTMClassifier, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0) self.lstm = nn.LSTM(embedding_dim, hidden_dim, batch_first=True) self.attention = nn.Linear(hidden_dim, 1, bias=False) self.fc = nn.Linear(hidden_dim, num_classes) self.dropout = nn.Dropout(dropout) def forward(self, input_ids): emb = self.embedding(input_ids) lstm_out, _ = self.lstm(emb) # [batch, seq_len, hidden_dim] attn_scores = self.attention(lstm_out) # [batch, seq_len, 1] attn_weights = torch.softmax(attn_scores, dim=1) # 对序列维度归一化 # 加权求和得到句子向量 context = torch.sum(lstm_out * attn_weights, dim=1) # [batch, hidden_dim] out = self.dropout(context) logits = self.fc(out) return logits注意力机制在这里的作用是替代“只取最后一步隐状态”的做法。最后一步隐状态要求模型把整个句子的信息压缩到一个向量里,长句子容易丢失开头信息;注意力机制则是把所有时间步的隐状态按权重相加,权重由模型自己学习,可解释性更强。训练完成后,对一条新文本同时输出预测结果和attn_weights最高的前5个词,这段展示在答辩时会让老师直接看到模型的判断依据。
5.3 把模型封装成可演示的推理接口
如果毕设要求里包含系统展示,可以做一个最简单的命令行交互或Flask接口。不用做前端,一个input()循环足够演示:
while True: text = input('请输入评论:') if text == 'exit': break result = predict_sentiment(text, model, word2idx) print(f'预测结果:{result["label"]},正面概率:{result["positive_prob"]},负面概率:{result["negative_prob"]}')用这种方式演示的好处是交互直观,老师可以随意输入测试文本,你能现场看到预测效果。我建议准备几条测试文本备用:一条包含明显情感词的正面评论,一条用反问或反讽表达的负面评论,一条夹杂网络热词的口语化评论。第一条用来展示模型正常能力,第二条用来展示模型在语义反转上的不足,第三条用来展示<UNK>带来的影响。提前展示模型的不足反而比只展示完美结果更可信,也能顺势说出未来的改进方向,比如引入BERT、加入更多训练语料、优化分词策略。
做项目的过程中我的个人习惯是每跑通一个阶段就做一次记录:数据清洗后各类样本数量、词表大小、每个epoch的准确率和损失。这些随手记录在写毕业论文的实验章节时能节省大量时间,不用回头重新跑实验。情感分析这个方向本身不新,LSTM也是一个成熟的模型,但能把数据、模型、训练、评估、演示每个环节都讲清楚,就已经是一份及格的毕业设计。希望这些踩坑经验能让你的项目推进得更顺利,也希望你在这个项目里不仅拿到代码,更拿到一套可以迁移到其他文本分类任务的方法论。
本文还有配套的精品资源,点击获取