旅游景点评论方面级别情感分析:语料库构建到BERT微调完整实践
发布时间:2026/10/1 3:30:43来源:尧图网络
简介面向Python毕业设计与课程设计场景这份资源以旅游景点评论为对象实现方面级别情感分析将语料库、模型训练与Django Web展示整合一体适合具备Python基础、需要完成NLP方向选题的计算机专业学生。压缩包大小70.56MB内含Django后端源码、景点评论语料数据、情感分析模型及权重、部署说明和项目文档覆盖从数据预处理、情感分类建模到Web系统开发的完整链路。目前已有101人学习其目录结构、代码风格与文档组织方式可作为毕业设计开题、实现和答辩的参考。项目涉及Django的MVT架构与ORM数据库操作、SQLite/MySQL等数据库设计、评论数据的采集清洗与标注、文本分词与停用词过滤、基于BERT/LSTM或传统机器学习的情感分析建模以及前端页面展示和gunicornNginx部署流程。读者可借此掌握方面级情感分析如何针对交通、卫生、票价等细粒度维度输出倾向理解模型调优与工程落地细节并进一步扩展语料、迁移模型到其他领域。1. 旅游景点方面级别情感分析毕设语料库与模型源码包到底在解决什么问题打开这个 zip里面装的是 python 毕业设计里最常见的两样东西一套带标注的旅游景点评论语料库和一份从数据预处理、模型训练到评估的情感分析源码。它要解决的不是“这条评论是好评还是差评”而是一个更细的问题一条「景色很美但缆车排队太久」的评论里景色和排队各自是正面还是负面。整句情感分析面对这种评论只能给一个综合极性通常是中性对景区管理几乎没有价值。方面级别情感分析把评论拆成景色、交通、设施、卫生等多个方面分别判极性让表扬和投诉精确落到对应部门。适合正在做毕设、想完整走一遍数据标注与建模流程的同学也适合想从整句情感升级到细粒度情感分析的从业者。2. 语料库搭建与标注体系方面类别怎么定、预处理脚本怎么写才不返工2.1 为什么旅游景点是方面级别情感分析最好的切入点景区评论的信息密度远高于普通商品评论。商品评论里一条差评通常只骂一个点比如质量不行景区评论完全不一样你打开任意平台看一条三星评价往往是“景色确实好早上人少很出片但缆车排了一个半小时下来连厕所都找不到”。这句话里至少有三个方面景色正面、排队负面、卫生负面。人工扫一眼就能拆清楚但让模型输出一个整体情感极性它会直接懵掉。所以旅游景点天然适合做方面级别情感分析Aspect-Based Sentiment AnalysisABSA的切入场景。它的评论语料不用刻意设计就自带多方面共现、情感冲突、口语化表达这些特征比商品评论更考验标注规范和模型结构。毕设选这个方向评审老师最容易问的“为什么不用 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(rhttps?://\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, encodingutf-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, encodingutf-8) as f: json.dump(samples, f, ensure_asciiFalse, indent2) print(f共生成 {len(samples)} 条标注记录)这段脚本做的事是把非结构化的标注结果转成模型可读的训练样本。category_map的作用是把中文类别名映射成固定的英文 key避免同一个类别出现“景色”“风景”“风光”三种写法。sentence_id这一行是整个脚本里最容易被忽略但最重要的字段后面划分训练验证集时必须按它聚合不能把同一条评论的标注记录随机拆开否则会出现让模型“提前看到答案”的数据泄漏指标虚高得离谱。预处理到这里就停不要急着分词。分词是在模型词表构建阶段做的事提前分词反而会把后续模型选型的空间堵死——比如你后面决定用 BERT它用的是自己的 WordPiece 词表你的 jieba 分词结果根本用不上。3. 模型源码路线选型从 TF-IDFSVM 到 BERT 微调的三条可复现路径3.1 三条路线怎么选效果、工作量与毕设收益的权衡方面级别情感分析的开源实现路径大致分三代第一代是特征工程加传统分类器第二代是 target 注意力机制结合 LSTM第三代是中文预训练模型微调。三者的区别不是简单的“新比旧好”而是工作量和可解释性的取舍。路线关键依赖效果水平代码工作量适合情况路线一TF-IDF SVMscikit-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_features10000, 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(kernellinear, class_weightbalanced) model.fit(X, y)这里有两个参数值得讲。ngram_range(1, 2)让特征里同时出现单词和相邻双词组合“景色”和“景色宜人”都能命中对中文这种复合词多的语言帮助很大。class_weightbalanced根据类别频率自动调整权重解决负面样本比正面样本少的问题。如果你发现 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_idx0) self.lstm nn.LSTM(embed_dim, hidden_dim, batch_firstTrue) 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(dim1) # 方面词向量平均 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), dim1) context (alpha * lstm_out).sum(dim1) # (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_labels3 ) def encode(text: str, aspect: str, max_len: int 128): return tokenizer( text, aspect, paddingmax_length, max_lengthmax_len, truncationTrue, return_tensorspt, )输入顺序是 text 在前、aspect 在后这个顺序让模型在处理长文本时不会丢失方面词的上下文锚点。max_length128是性价比比较高的阈值景区评论平均长度在 60 到 80 字给足余量太短会截断有效信息太长会浪费显存。paddingmax_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_idsinput_ids, attention_maskattention_mask, labelslabels, ) 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_code500, detailstr(e))部署完成后一定要做一轮冒烟测试用人工写好的 20 条评论跑一遍覆盖 7 个方面类别、正负中三种极性以及“两个方面共现”的典型场景。验收标准是每一条评论都能按方面正确切出结果并且“排队”和“卫生”这两个难点类别没有把负面误判成中性。我自己做这类项目有个习惯每换一个数据集先跑数据分布统计再训练模型最后才看指标。翻车最惨的一次就是没检查句子级别泄漏直接按标注记录随机切分测试集 F1 达到了 87%换了正确切分方式后掉到 72%从此再也不敢跳过数据切分检查。希望这份从标注规范到模型落地的完整路线能帮你少踩几个同样的坑把毕设真正做成一个拿得出手的完整工作。本文还有配套的精品资源点击获取
网站建设高端定制企业官网