简介:面向Python毕业设计与课程设计场景,这份资源以旅游景点评论为对象,实现方面级别情感分析,将语料库、模型训练与Django Web展示整合一体,适合具备Python基础、需要完成NLP方向选题的计算机专业学生。压缩包大小70.56MB,内含Django后端源码、景点评论语料数据、情感分析模型及权重、部署说明和项目文档,覆盖从数据预处理、情感分类建模到Web系统开发的完整链路。目前已有101人学习,其目录结构、代码风格与文档组织方式可作为毕业设计开题、实现和答辩的参考。项目涉及Django的MVT架构与ORM数据库操作、SQLite/MySQL等数据库设计、评论数据的采集清洗与标注、文本分词与停用词过滤、基于BERT/LSTM或传统机器学习的情感分析建模,以及前端页面展示和gunicorn+Nginx部署流程。读者可借此掌握方面级情感分析如何针对交通、卫生、票价等细粒度维度输出倾向,理解模型调优与工程落地细节,并进一步扩展语料、迁移模型到其他领域。
1. 旅游景点方面级别情感分析毕设:语料库与模型源码包到底在解决什么问题
打开这个 zip,里面装的是 python 毕业设计里最常见的两样东西:一套带标注的旅游景点评论语料库,和一份从数据预处理、模型训练到评估的情感分析源码。它要解决的不是“这条评论是好评还是差评”,而是一个更细的问题:一条「景色很美但缆车排队太久」的评论里,景色和排队各自是正面还是负面。整句情感分析面对这种评论只能给一个综合极性,通常是中性,对景区管理几乎没有价值。方面级别情感分析把评论拆成景色、交通、设施、卫生等多个方面分别判极性,让表扬和投诉精确落到对应部门。适合正在做毕设、想完整走一遍数据标注与建模流程的同学,也适合想从整句情感升级到细粒度情感分析的从业者。
2. 语料库搭建与标注体系:方面类别怎么定、预处理脚本怎么写才不返工
2.1 为什么旅游景点是方面级别情感分析最好的切入点
景区评论的信息密度远高于普通商品评论。商品评论里一条差评通常只骂一个点,比如质量不行;景区评论完全不一样,你打开任意平台看一条三星评价,往往是“景色确实好,早上人少很出片,但缆车排了一个半小时,下来连厕所都找不到”。这句话里至少有三个方面:景色(正面)、排队(负面)、卫生(负面)。人工扫一眼就能拆清楚,但让模型输出一个整体情感极性,它会直接懵掉。
所以旅游景点天然适合做方面级别情感分析(Aspect-Based Sentiment Analysis,ABSA)的切入场景。它的评论语料不用刻意设计就自带多方面共现、情感冲突、口语化表达这些特征,比商品评论更考验标注规范和模型结构。毕设选这个方向,评审老师最容易问的“为什么不用 BERT 直接分类”也比较好回应——因为单标签分类任务根本不匹配这个数据分布。
2.2 标注体系:方面类别、情感极性、标注规范
动手标注前必须先定一套“标注协议”,否则标到一半发现两个人的答案对不上,返工成本极高。常见做法是把方面类别固定在一张表里,每条评论拆成若干条(方面词, 方面类别, 情感极性)的标注记录。
| 方面类别 | 推荐 key | 典型触发词 | 示例标注 |
|---|---|---|---|
| 景色风光 | scenery | 景色、风景、壮观、优美、出片 | 「景色很美」→ pos |
| 交通位置 | transport | 地铁、停车、公交、离市区 | 「停车很不方便」→ neg |
| 门票价格 | price | 门票、票价、性价比、优惠 | 「门票太贵了」→ neg |
| 设施体验 | facility | 缆车、电梯、栈道、索道、排队 | 「缆车排队太久」→ neg |
| 环境卫生 | environment | 干净、垃圾、厕所、脏 | 「厕所很干净」→ pos |
| 服务态度 | service | 工作人员、服务、态度、讲解 | 「讲解员很耐心」→ pos |
极性分成三档:pos、neg、neu。neu不是摆设,它承接那些“说到了一个方面但没给态度”的句子,比如“景区门口有地铁站”这种纯事实陈述。标注规范里要写清楚的是:方面词必须在原句里真实出现,不能自己概括;类别只能从表里选;一个句子可以有多条标注。
还有一个容易含糊的地方:如果语料里“排队”相关表达超过总量的 5%,我一般建议把它从设施体验里拆出来,单列一个queue类别。否则“缆车排队太久”和“缆车本身很稳”会被揉进同一个类别里,模型内部特征互相干扰。
2.3 标注流程与一致性校验
标注工具上,开源方案用 Label Studio 或 Doccano 都行,做文本分类标注完全够用;如果嫌部署麻烦,直接用共享 Excel 或 JSON 协作也可以,但项目过程资料会难看一点,毕设答辩时没有“标注规范文档”会吃亏。
流程我建议分三步走。第一步,先标 30 到 50 条,让两个标注者独立标,然后算 Cohen’s kappa 一致性系数,低于 0.6 说明规范没写清楚,先回去改规范;高于 0.7 再正式放开标。第二步,正式标注阶段一人标、一人抽检,抽检比例不用太高,10% 左右能拦住明显的走神。第三步,标注完成后做一轮清洗,把方面词不在原句里的、类别选了 “other” 的、极性三人都拿不准的记录全部剔掉。
关于语料规模,经验值是单类别至少 200 条标注,总标注记录数控制在 1500 到 3000 条之间。这个量对毕业设计足够,对一个人工标注团队也现实。再多就会把毕设拖成标注苦力活,模型收益还会边际递减。
2.4 预处理脚本:清洗、结构化、保留句子 ID
拿到原始 JSON 语料后,第一件事不是分词,而是做文本清洗和结构化。清洗要去 HTML 标签、URL、多余空白;结构化要把一条评论拆成多条“标注记录”,并且保留sentence_id字段。这个字段后面有大用,决定训练集和测试集能不能按句子切分。
import json import re def clean_text(text: str) -> str: text = re.sub(r"<[^>]+>", "", text) # 去 HTML 标签 text = re.sub(r"https?://\S+", "", text) # 去 URL text = re.sub(r"\s+", " ", text) # 合并多余空白 return text.strip() def build_dataset(raw_path: str, out_path: str, category_map: dict): samples = [] with open(raw_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue rec = json.loads(line) text = clean_text(rec["text"]) for a in rec["aspects"]: samples.append({ "text": text, "aspect_term": a["term"], "aspect_category": category_map.get(a["category"], "other"), "polarity": a["polarity"], "sentence_id": rec["id"], # 关键字段:按句子切分的依据 }) with open(out_path, "w", encoding="utf-8") as f: json.dump(samples, f, ensure_ascii=False, indent=2) print(f"共生成 {len(samples)} 条标注记录")这段脚本做的事是把非结构化的标注结果转成模型可读的训练样本。category_map的作用是把中文类别名映射成固定的英文 key,避免同一个类别出现“景色”“风景”“风光”三种写法。sentence_id这一行是整个脚本里最容易被忽略但最重要的字段,后面划分训练验证集时必须按它聚合,不能把同一条评论的标注记录随机拆开,否则会出现让模型“提前看到答案”的数据泄漏,指标虚高得离谱。
预处理到这里就停,不要急着分词。分词是在模型词表构建阶段做的事,提前分词反而会把后续模型选型的空间堵死——比如你后面决定用 BERT,它用的是自己的 WordPiece 词表,你的 jieba 分词结果根本用不上。
3. 模型源码路线选型:从 TF-IDF+SVM 到 BERT 微调的三条可复现路径
3.1 三条路线怎么选:效果、工作量与毕设收益的权衡
方面级别情感分析的开源实现路径大致分三代:第一代是特征工程加传统分类器;第二代是 target 注意力机制结合 LSTM;第三代是中文预训练模型微调。三者的区别不是简单的“新比旧好”,而是工作量和可解释性的取舍。
| 路线 | 关键依赖 | 效果水平 | 代码工作量 | 适合情况 |
|---|---|---|---|---|
| 路线一:TF-IDF + SVM | scikit-learn | 中下 | 小 | 快速跑通对比基准 |
| 路线二:AT-LSTM + 注意力 | PyTorch | 中 | 中 | 展示模型细节与注意力可视化 |
| 路线三:BERT 微调 | transformers | 好 | 中 | 主模型,追求准确率和答辩效果 |
做毕设最稳的方案是三个都跑,路线一当基准,路线二是你自己实现的模型结构,路线三是效果上限。答辩时老师问你“为什么不用 XX”,你能拿出三条路线的对比实验数据,这个说服力远高于只贴一个 BERT 的训练日志。
3.2 路线一:TF-IDF 拼接方面词特征加 SVM
第一个可跑通的最小样例,思路是把评论文本和方面词分别做 TF-IDF 向量化,然后水平拼接成一条特征向量,交给线性 SVM 分类。拼方面词特征的目的是让分类器知道“当前要判断的是哪个方面”,这比只把整句评论喂进去要靠谱得多。
import scipy.sparse as sp from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import SVC def build_features(samples): texts, aspects, y = [], [], [] for s in samples: texts.append(s["text"]) aspects.append(s["aspect_term"]) y.append(s["polarity"]) return texts, aspects, y texts, aspects, y = build_features(train_samples) vec_text = TfidfVectorizer(max_features=10000, ngram_range=(1, 2)) vec_aspect = TfidfVectorizer(ngram_range=(1, 2)) X_text = vec_text.fit_transform(texts) X_aspect = vec_aspect.fit_transform(aspects) X = sp.hstack([X_text, X_aspect]) # 水平拼接两个稀疏矩阵 model = SVC(kernel="linear", class_weight="balanced") model.fit(X, y)这里有两个参数值得讲。ngram_range=(1, 2)让特征里同时出现单词和相邻双词组合,“景色”和“景色宜人”都能命中,对中文这种复合词多的语言帮助很大。class_weight="balanced"根据类别频率自动调整权重,解决负面样本比正面样本少的问题。如果你发现 SVM 预测结果里几乎没有neg,回来检查这一行,多半是忘了加。
这个路线的上限很低,因为 TF-IDF 只看词频,不理解“但”“虽然”“居然”这类转折词的作用。但它作为基线有一个不可替代的价值:用它跑一遍完整流程,能逼你把数据格式、标签映射、评估代码全部调通,后面换模型只是替换中间的建模段。
3.3 路线二:AT-LSTM——把方面词融进注意力机制
如果毕设里需要有“自己的模型结构”,我一般建议用 AT-LSTM 而不是直接调 BERT。这个结构的思路很直接:用 LSTM 编码整句评论,把方面词向量做平均后投影成与隐状态同维的向量,然后把每个位置的隐状态与方面向量拼接,过一层注意力得到上下文表示,最后接分类层。
import torch import torch.nn as nn class ATLSTM(nn.Module): def __init__(self, vocab_size, embed_dim, hidden_dim, num_labels): super().__init__() self.emb = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.lstm = nn.LSTM(embed_dim, hidden_dim, batch_first=True) self.target_proj = nn.Linear(embed_dim, hidden_dim) self.attn = nn.Linear(hidden_dim * 2, 1) self.fc = nn.Linear(hidden_dim, num_labels) def forward(self, sent_ids, aspect_ids): sent_emb = self.emb(sent_ids) # (B, L, D) aspect_vec = self.emb(aspect_ids).mean(dim=1) # 方面词向量平均 lstm_out, _ = self.lstm(sent_emb) # (B, L, H) target = self.target_proj(aspect_vec).unsqueeze(1) # (B, 1, H) target = target.expand(-1, lstm_out.size(1), -1) attn_in = torch.cat([lstm_out, target], dim=-1) # (B, L, 2H) alpha = torch.softmax(self.attn(attn_in), dim=1) context = (alpha * lstm_out).sum(dim=1) # (B, H) return self.fc(context)注意力在这里的作用是告诉模型:现在判断的是“景色”而不是“缆车”,所以应该把注意力集中在“美”“壮观”这些词上,而不是“排队”。实现细节上,aspect_ids是方面词在词表里的下标序列,长度和句子的长度无关,用一个简单平均池化生成方面表示,再过一层线性变换与 LSTM 隐状态对齐。损失函数直接用交叉熵,优化器选 Adam,学习率从 1e-3 开始调。
这条路线对毕设有一个额外好处:注意力权重alpha可以拿出来可视化。把每个词的权重叠加到原文上画成热力图,答辩展示的是“我的模型知道在看哪儿”,这个效果比任何指标数字都直观。
3.4 路线三:中文预训练模型微调,输入里塞方面词
用 BERT 做方面级别情感分析时,最常见的做法是把评论与方面词用 [SEP] 分隔后拼接输入,取 [CLS] 位输出接分类头。比如「景色很美但缆车排队太久」配方面词「景色」,输入变成「[CLS] 景色很美但缆车排队太久 [SEP] 景色 [SEP]」。模型能同时看到上下文和当前的判断对象。
from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3 ) def encode(text: str, aspect: str, max_len: int = 128): return tokenizer( text, aspect, padding="max_length", max_length=max_len, truncation=True, return_tensors="pt", )输入顺序是 text 在前、aspect 在后,这个顺序让模型在处理长文本时不会丢失方面词的上下文锚点。max_length=128是性价比比较高的阈值,景区评论平均长度在 60 到 80 字,给足余量,太短会截断有效信息,太长会浪费显存。padding="max_length"让一个 batch 里所有样本长度一致,方便矩阵并行运算。
如果你发现验证集上neg的 F1 明显低于pos,多半不是模型问题,而是标注样本里负面占比不够。此时优先别换模型,先把训练数据按类别重新采样或者做数据增强。预训练模型微调阶段还有一个常见误区是冻结全部 BERT 参数只训练分类头,这样做的效果一般比从零训练还差,因为方面级别的语义差异恰恰需要通过微调才能被激活。
4. 训练、评估与误差分析:用宏平均 F1 和混淆矩阵验货,避免指标虚高
4.1 数据切分:按句子聚合,别让同一条评论跨集合
切分是翻车率最高的环节。把训练样本随机打乱再按 8:1:1 切分,表面看没问题,实际上同一条评论的“景色”标注可能落在训练集,“卫生”标注落在测试集。模型在训练时已经见过同一句话的上下文,测试时再见到同一句话的另一个标注,等于开卷考试,指标虚高 10 到 20 个点都有可能。
正确的做法是先按sentence_id聚合,把同一句话的所有标注记录绑在一起,再整体切分。代码上可以先把句子级别的 id 列表随机打散,按比例取前 80% 的句子作为训练集,剩下 20% 按 1:1 拆验证集和测试集。
类别分布也要先查一遍。景区评论的情感分布天然不均衡,正面往往占一半以上,负面次之,中性最少。如果发现“排队”类别的负面样本极少,先记下来,后面用class_weight或者训练时对少样本类别加权。
4.2 评估指标:准确率会骗人,宏平均 F1 才是主指标
二分类时代大家习惯看准确率,但在三个方面类别加多类别的任务里,准确率毫无参考价值。假设数据里 60% 是pos,模型把所有样本都预测成pos,准确率也有 60%,看起来“还行”,实际上等价于没学。正确的指标组合是每个类别的精确率、召回率、F1,以及这三个类别的宏平均 F1。
宏平均 F1 是把三个类别的 F1 先各自算出来再取平均,它给少数类别和多数类别相同的权重,能真实反映模型在“中性”这类低频样本上的表现。混淆矩阵也不能省,它告诉你模型把“负面”错认成了“正面”还是“中性”,这两个错误的原因完全不同。
| 指标 | 含义 | 在 ABSA 里的参考价值 |
|---|---|---|
| 准确率 | 全部预测里猜对的比例 | 低,类别不平衡时虚高 |
| 宏平均 F1 | 各类别 F1 的算术平均 | 高,主指标 |
| 加权 F1 | 按样本量加权的 F1 | 中,兼顾样本规模 |
| 混淆矩阵 | 错误方向的可视化 | 高,定位哪两类最易混淆 |
4.3 训练脚本与指标输出
无论选哪条路线,训练循环都长一个样。用 PyTorch 写一个标准的微调循环,包含前向传播、反向传播、优化器更新和一轮结束后的验证评估。
import torch from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, device): model.train() total_loss, correct, total = 0.0, 0, 0 for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) outputs = model( input_ids=input_ids, attention_mask=attention_mask, labels=labels, ) loss = outputs.loss loss.backward() optimizer.step() optimizer.zero_grad() total_loss += loss.item() preds = outputs.logits.argmax(dim=-1) correct += (preds == labels).sum().item() total += labels.size(0) return total_loss / len(loader), correct / total训练循环本身很朴素,需要强调的是三个环境细节。第一,optimizer.zero_grad()必须在loss.backward()之前调用,否则梯度会跨 batch 累加,导致更新方向错乱。第二,验证阶段必须写model.eval()并用torch.no_grad()包住前向计算,否则 dropout 和批归一化还在启用,验证指标会抖动得很厉害。第三,固定随机种子,在训练脚本开头加torch.manual_seed(42),否则每次跑的指标都不一样,答辩时别人会质疑结果可复现性。
4.4 误差分析:导出 bad case,按类别逐个看
训练结束后不要只看三个数字就收工。把验证集里预测错的样本连同真实标签、预测标签一起导出成 CSV,按方面类别分组统计,找出 F1 最低的那个类别。
常见的结论是“门票价格”和“交通位置”这类短文本方面最容易被搞错,因为训练样本里涉及它们的评论往往较短,情感词比较模糊,“价格还行”“不算太远”这种话模型很难判断极性。另一种高发错误是转折结构,比如“虽然排队久但景色值得”,模型容易把前面“排队久”的负面情绪传导到“景色”上。
误差分析的产出不是一篇反思,而是三类改动依据:哪些方面类别需要补标注样本、哪些样例需要修正标签、哪些输入格式需要调整(例如把对比句按逗号切成两个短句再分别判断)。
提示:评估阶段保存每次实验的数据切分方式、随机种子和超参数,方便复现。记录实验是毕设答辩里最容易加分的一环。
5. 常见问题避坑与排查:语料标注、中文分词、训练环境三处容易翻车的地方
5.1 标注一致性差导致模型学了一堆“标准答案”的噪声
现象:两个标注者独立标同一批数据,kappa 系数只有 0.5,训练出来模型验证集 F1 死活徘徊在 60% 上不去。
原因:标注协议里没写清楚“方面词没出现在原句中怎么办”、“句子提到多个景点时怎么归属类别”。比如“东门比南门人少”这句话,有的标注者给“东门”标了环境类别,有的标了设施类别。
解决:先停训练,回去把标注规范补齐,重新做一轮 30 到 50 条的小样本一致性测试,kappa 达到 0.7 再继续。标注规范里要写死一条规则:方面词必须是原文出现过的字面片段,任何人不能用自己的话概括。
5.2 jieba 分词把领域词汇切碎,传统模型特征失效
现象:路线一的 TF-IDF 模型效果奇差,检查特征矩阵发现“人文景观”被切成“人文”和“景观”,“湖光山色”被切成三个碎片。
原因:jieba 通用词典里没有这些景区词,默认按二元语法组合,把完整概念拆散了,TF-IDF 只看零散字词,丢失了方面词的完整语义。
解决:加载自定义词典,把语料库里出现频率高的方面词和景点词提前加进去。
import jieba jieba.add_word("人文景观") jieba.add_word("缆车排队") jieba.add_word("湖光山色") # 或者批量加载: jieba.load_userdict("domain_words.txt")这个坑只影响路线一和路线二,因为这两条路线依赖分词结果作为词表;路线三用 BERT 自带的 WordPiece 词表,不受影响。如果你选了传统路线,预处理流程里必须加这一步,否则后面调什么都白搭。
5.3 一个句子里多个方面共享同一个情感词,极性归属打架
现象:输入「景色很美,但人太多很失望」,判断“景色”时输出负面。
原因:模型把“失望”的负面情绪错误传导给了“景色”。“但”这个转折词让句子的情感重心发生了偏移,但 LSTM 和 BERT 对这种长距离依赖的建模能力并不像想象中那么强。
解决:数据层面把“从句切分”做成预处理步骤,按逗号、分号、句号把长句拆成短句,让每个短句里最多保留一个主要方面。模型层面,可以在输入时显式加入方面词的位置标记,让注意力更有针对性。
这个坑在真实景区评论里出现频率很高,因为用户写点评经常是一次性罗列三四个方面,每个方面配一个态度。切句会损失一点上下文信息,但换来的是标签与文本片段的一一对应,模型收敛明显变快。
5.4 训练环境资源不够:CPU 跑 BERT 微调慢到怀疑人生
现象:笔记本 CPU 跑一个 epoch 要 40 分钟,还没等训练完,电脑风扇声已经大得像在开飞机。
原因:BERT 参数量太大,CPU 算力不足以支撑多轮微调。
解决:三个方向降成本。一是把max_length从 128 降到 64,景区评论截断损失不大。二是调小batch_size,如果显存不够,用梯度累积来模拟大 batch。三是用混合精度训练,在支持 GPU 的环境里能近乎减半显存占用。实在没有 GPU 又不想花钱,就把路线三的模型从 BERT 换成更小的中文预训练模型,比如 6 层 768 维的轻量版本,效果损失通常在可接受范围内。
5.5 口语化和新词导致预测偏中性,模型变成“和稀泥”
现象:“景色绝绝子”“非常出片”这类网络热评,模型通通输出中性。
原因:新兴网络词汇不在训练集里,也没有预训练词表覆盖,注意力被分散到邻近的常规词上,模型找不到明确的情感信号。
解决:训练集里人工补充 20 到 30 条高热度网络表达,用同义改写扩充。例如“绝绝子”标注为“景色”方面下的pos,“踩雷”标注为对应方面的neg。对路线一来说,需要把这些表达加进自定义词典和 TF-IDF 词表;路线三只需要加训练样例。
注意:网络热词更新极快,标注时只标注语料里真实出现的,不要主动在训练集里编造网络用语,避免模型过拟合到特定年份的表达。
6. 进阶:把分类模型扩展成「方面词抽取 + 情感分类」的 HTTP 服务
毕设做到这里,模型能跑、指标能看,但还差一步——怎么让整个方案“用起来”。最常见的进阶方向是把单模型分类扩展成两条流水线:先抽方面词,再按方面词逐一判断情感。简单做法不需要上序列标注模型,用词表和规则先抽候选方面词,再喂给已经训好的分类模型。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() class ReviewInput(BaseModel): text: str ASPECT_KEYWORDS = ["景色", "门票", "交通", "缆车", "卫生", "服务", "排队"] def extract_aspects(text: str) -> list[str]: matched = [kw for kw in ASPECT_KEYWORDS if kw in text] return matched if matched else [""] # 空串表示整句判断 @app.post("/aspect-sentiment") def predict(review: ReviewInput): try: aspects = extract_aspects(review.text) results = [] for asp in aspects: results.append({ "aspect": asp, "polarity": model_predict(review.text, asp), }) return {"text": review.text, "results": results} except Exception as e: raise HTTPException(status_code=500, detail=str(e))部署完成后一定要做一轮冒烟测试,用人工写好的 20 条评论跑一遍,覆盖 7 个方面类别、正负中三种极性,以及“两个方面共现”的典型场景。验收标准是每一条评论都能按方面正确切出结果,并且“排队”和“卫生”这两个难点类别没有把负面误判成中性。
我自己做这类项目有个习惯:每换一个数据集,先跑数据分布统计,再训练模型,最后才看指标。翻车最惨的一次就是没检查句子级别泄漏,直接按标注记录随机切分,测试集 F1 达到了 87%,换了正确切分方式后掉到 72%,从此再也不敢跳过数据切分检查。希望这份从标注规范到模型落地的完整路线,能帮你少踩几个同样的坑,把毕设真正做成一个拿得出手的完整工作。
本文还有配套的精品资源,点击获取