资讯动态

旅游景点方面级情感分析毕设:语料标注与模型微调实战

发布时间:2026/10/9 12:38:20 来源:尧图企业网站定制
简介适用于python毕业设计或课程设计的旅游景点方面级别情感分析系统压缩包内含旅游评论语料库与模型源码。项目以Django框架为后端基础涉及MVT架构、ORM数据库增删改查、路由与视图编写情感分析环节可采用BERT、LSTM等预训练模型也可使用朴素贝叶斯、支持向量机等经典机器学习算法配套语料库经过人工标注用于训练正面、负面、中性情感分类器。通过源码可完整掌握从数据清洗、特征工程、模型训练到评估部署的流程并能学习前端页面如何展示景点信息与分析结论。压缩包整体约70.56MB内部包含项目源码、语料库、部署说明与说明文档目录结构清晰适合按模块逐步研读。目前已有101人学习下载比较适合需要完成毕业设计或课程设计的高校学生也可作为学习Web开发、数据库设计与自然语言处理实战项目的范例。1. 旅游景点方面级别情感分析毕设选它好在哪难在哪如果你已经看腻了那种把整条评论丢进模型、只输出“好评”或“差评”的情感分析项目那“旅游景点方面级别情感分析语料库与模型源码”这个标题应该能让你停下来多看几秒。方面级别情感分析Aspect-Based Sentiment AnalysisABSA解决的问题很具体游客说“风景很美但停车太贵”整句判断是矛盾的而景区运营方真正想知道的恰恰是“风景”被夸了、“停车”被骂了。这个方向做毕业设计能同时覆盖语料标注、序列建模、分类评测三条技能线数据不难找模型也有成熟的中文预训练底座兜底属于投入产出比很高的一类选题。适合的人也很明确想做 NLP 方向、需要一份能讲清楚数据流和模型细节、且演示效果直观的毕设项目而不是只交一个黑匣子。后面几章我按“标注规范怎么定、数据怎么转、模型怎么训、坑在哪、怎么演示”的顺序把这条路径完整拆开。2. 语料库是第一道坎景点评论的标注方案与三元组格式2.1 先定“方面”分类体系交通、门票、景色之外的类别怎么给做方面级别情感分析第一步不是写代码是把“方面”这个标签体系定死。定死了后面的标注、模型、评测才不会返工。旅游景点评论里最常见的方面类别是位置交通、门票价格、景色风光、游玩设施、餐饮购物、卫生环境、服务管理这七类但也别照搬要对着你手头的数据看。比如某景点评论里“排队两小时”出现频率极高那就拆出一个“排队体验”类“周边民宿宰客”很多那就拆出“周边配套”类。分类体系的粒度决定了标注成本和模型难度类太少模型学不出区分度类太多每个类样本不够F1 会很难看。一般控制在 8 到 12 个类别之间每类样本量不低于 200 条。我给一个可以直接参考的类别表演示类目和标注例句。方面类别标注代码例句该句极性位置交通TRAFFIC地铁出来走十分钟就到还算方便正面门票价格PRICE门票八十逛一小时就出来不值负面景色风光SCENERY山顶看日落简直绝了正面游玩设施FACILITY索道排队太久体验一般负面卫生环境HYGIENE厕所很干净加分正面服务管理SERVICE工作人员态度冷淡负面标注规范要写到“能直接复制粘贴给标注员看”的程度而不是自己脑子里清楚。每个类别要配一句“包含什么、不包含什么”的边界说明比如“餐厅口味”算餐饮还是景色要先约定。遇到实在塞不进去的留一个“其他”类兜底但“其他”的占比必须低于 5%一旦超过说明你的类别体系没分对回去改类目而不是硬标。这一步看着琐碎实则决定了整条数据流的质量上限后面模型精度不够八成是这里埋的雷。2.2 四元组标注把一个句子拆成可训练的结构标注单元建议用“四元组”方面词、方面类别、观点词、情感极性即 (aspect term, aspect category, opinion term, polarity)。以“风景很美但停车太贵”为例它包含两个四元组“风景 / SCENERY / 美 / 正面”和“停车 / TRAFFIC / 贵 / 负面”。一个句子里多个方面就要标多组这是方面级和整句级最大的区别。实现上我会把标注结果直接落成 CSV一行一个四元组句子文本冗余存储方便后面检查。下面这段代码是初始化标注文件时最常用的小脚本顺手把同一句子的多个方面合并到一行。import csv from collections import defaultdict # 输入的原始标注格式为 list of dict raw_annotations [ {sent_id: 1, text: 风景很美但停车太贵, aspect: 风景, category: SCENERY, opinion: 美, polarity: POS}, {sent_id: 1, text: 风景很美但停车太贵, aspect: 停车, category: TRAFFIC, opinion: 贵, polarity: NEG}, ] # 按句子聚合一个句子可能有多组四元组 grouped defaultdict(list) for item in raw_annotations: grouped[item[sent_id]].append(item) with open(annotations.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([sent_id, text, aspect, category, opinion, polarity]) for sid, items in grouped.items(): text items[0][text] for it in items: writer.writerow([sid, text, it[aspect], it[category], it[opinion], it[polarity]])这里有两个参数值得注意。一是编码用utf-8-sig而不是utf-8否则你用 Excel 打开 CSV 时中文会乱码标注员没法看二是用sent_id而不是直接贴文本做主键因为同一句话拆成多个方面后文本重复出现后面合并训练样本时靠 id 去重更稳。观点词在某些句子里找不出来比如“不值”这类评价里没有明确的观点名词这时把观点词留空但极性不能空。隐式方面也一样“太贵了”没出现“门票”两个字方面词留空或者标“NULL”类别标 PRICE模型侧后面会单独处理空方面样本不要为了凑四元组强行编一个词。2.3 标注一致性两个人标出不一样怎么办标注这步最容易翻车的地方不是不会标而是两个人对同一条评论的理解不一致。你说“景色很美”是正面他说“景色”是 SCENERY 但“美”是观点词结果一个标了四元组、一个漏标了观点词数据就脏了。解决标准做法是至少两个人独立标注同一批数据然后算一致性不一致的交给第三人仲裁或讨论达成共识。计算一致性的工具不复杂用 Cohen’s Kappa 就行。对四元组标注可以分别算“方面词是否一致”和“极性是否一致”也可以只算最终四元组是否完全相同后者更严格。from sklearn.metrics import cohen_kappa_score # 两个标注员对 100 条样本的方面词标注结果 annotator_a [风景, 停车, 门票, , 风景, ...] # 为空表示无方面 annotator_b [风景, , 门票, , 风景, ...] kappa cohen_kappa_score(annotator_a, annotator_b) print(fKappa {kappa:.3f}) # 一般标准kappa 0.7 认为标注可用低于 0.5 必须回炉这里的 Kappa 值不是玄学它是断定“该回炉还是该继续”的硬指标。第一次标注 Kappa 低于 0.5 很正常不用慌把分歧样本拉出来看基本都是类别边界没写清回到 2.1 补规范再标第二轮通常会升到 0.7 以上。如果一直卡在 0.6 上不去大概率不是标注员的问题而是某个类别本身定义模糊比如“景色”和“环境”在实际评论里几乎无法区分这时合并类目比硬拗规范更务实。3. 从语料到训练样本用 Python 把四元组转成 BIO 序列3.1 数据清洗与句子切分直接复用你的正则库拿到标注好的四元组 CSV接下来要把它转成模型能吃的样本。第一步是清洗和切句。旅游评论是口语文本里面常混着表情、URL、“哈哈哈哈”这种叠字还有繁体。清洗原则是“能少删就少删”表情符号里往往带着情绪但 emoji 进 BERT 会被切成碎片甚至变成[UNK]我一般直接移除URL 和 用户名移除繁体转简体用opencc连续重复标点压缩成一个。切句不要迷信split(。)感叹号和问号也得切而且“但是”“不过”开头的残句要并回上一句。下面是一个能直接跑的切句函数。import re def split_sentence(text): # 按句号、感叹号、问号切分保留切分结果 parts re.split(r([。!?]), text) sentences [] buf for i in range(0, len(parts) - 1, 2): seg parts[i] parts[i 1] # 合并以转折词开头的残句 if buf and re.match(r^(但是|不过|然而|可是|但), seg): buf seg else: if buf: sentences.append(buf) buf seg if buf: sentences.append(buf) return [s.strip() for s in sentences if len(s.strip()) 2] if __name__ __main__: text 风景很美但是停车太贵厕所也很干净。 print(split_sentence(text)) # 输出: [风景很美但是停车太贵, 厕所也很干净。]切句这一步直接影响后续标注对齐。原 CSV 里如果按整条评论标了四元组切完句后要重新确认每个四元组落在哪个句子里否则会出现“上面句子标了方面、下面句子没标”的错位。我习惯的做法是切句之后再跑一遍“四元组文本是否包含于句子”的校验包含不上的直接打印出来人工看不要静默丢弃。注意正则里把中文和英文标点都列进去了因为旅游评论里“!!”混用很常见。3.2 把四元组转成序列标注BIO 标签的分配逻辑模型要学的核心任务是“找出句子里的方面词和观点词”这事通常建模成序列标注用 BIO 体系每个字标B-Aspect、I-Aspect、B-Opinion、I-Opinion或O。以“风景很美”为例标注结果是“风 B-Aspect / 景 I-Aspect / 很 O / 美 B-Opinion”。注意“风景”是两字词第一个字B后续字I只有一个字的方面词直接B不标I。实现这个转换有个关键点四元组里的方面词和观点词在句子里可能出现多次只标第一次匹配会让模型学歪。我一般要求标注阶段记录起止位置没有位置信息就按“第一个匹配”处理但这会漏掉重复词比如“漂亮真的漂亮”里的第二个“漂亮”实际上是对同一个方面二次评价。下面这段代码把 CSV 转成字级 BIO 标签。def assign_bio(text, aspect, opinion): 将单个句子的四元组映射为字级 BIO 标签 chars list(text) labels [O] * len(chars) # 标记方面词多个词之间的其余字保持 O if aspect and aspect ! NULL: # 查找所有非重叠出现位置取第一个 start text.find(aspect) if start ! -1: labels[start] B-Aspect for i in range(start 1, start len(aspect)): labels[i] I-Aspect if opinion and opinion ! NULL: start text.find(opinion) if start ! -1: labels[start] B-Opinion for i in range(start 1, start len(opinion)): labels[i] I-Opinion return chars, labels # 示例text风景很美 chars, labels assign_bio(风景很美, 风景, 美) for c, l in zip(chars, labels): print(c, l)这个函数里“取第一次出现”是最粗的匹配策略如果你标注数据里已经带了起止位置直接按位置填就行别再用find。还要注意aspect和opinion重叠的情况比如“景色不错”里“景色”是方面、“不错”是观点不重叠但“小吃不错”里“小吃”既可以是方面词又偶尔被当成观点词这时先标方面再标观点后标的会把先标的覆盖掉严谨的做法是给类型加权重B-Aspect优先级高于B-Opinion。大部分毕设样本不会这么极端但你要知道有这个坑。3.3 训练样本构造与标签对齐中文 BERT 一字一 token 也要防边界序列标注模型通常用 BERT 做编码层所以要把 BIO 标签和 BERT 的 token 对齐。中文 BERT 基本是一字一 token理论上直接按字符顺序填标签就行但实际有例外全角标点、数字、英文会被 tokenizer 合并[CLS]和[SEP]也要占位置。直接label_ids [-100] char_labels [-100]的做法在“3D 影院”这种串上会全错位。稳妥的办法是让 tokenizer 返回offset_mapping拿到每个 token 对应的字符区间再对齐。from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(bert-base-chinese) def encode_with_labels(text, char_labels, max_len128): # 返回 input_ids, attention_mask, label_ids enc tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_offsets_mappingTrue, ) offsets enc[offset_mapping] label_ids [-100] * len(enc[input_ids]) # -100 表示忽略该位置 # [CLS] 的 offset 是 None[SEP] 的 offset 也是 None跳过 for idx, (start, end) in enumerate(offsets): if start is None or end is None: continue # 取该 token 第一个字符对应的 BIO 标签 label_ids[idx] label_map[char_labels[start]] return enc[input_ids], enc[attention_mask], label_ids这里label_map是把字符串标签转成数字 id 的字典比如{O: 0, B-Aspect: 1, ...}。-100是 PyTorch 交叉熵损失里约定俗成的“忽略位”[CLS]和[SEP]不参与损失计算。有几个参数要按你的数据调max_len128对旅游评论够用如果切完句还有长句调到 256 也行但显存占用会涨truncationTrue会硬截断截断点恰好落在方面词中间会导致标签丢失我一般先把超过 120 字的句子人工切一刀而不是纯靠 truncation。构造完样本后务必打印一条肉眼检查字、token、标签三个列表对齐了再进下一步。3.4 抽取和极性分类一条管线还是两个模型数据转成 BIO 后只是完成了“抽取”这半边。极性分类另有一条路常见的做法是搭成两段式管线模型 A 做方面/观点抽取输出句子里的 aspect span模型 B 拿到每个 aspect span 和完整句子分类为正、负、中性。两段式的好处是能分别调参、分别看错误答辩时也更好讲清楚——每一步都有中间产物可以展示。联合模型一个模型同时输出 span 和极性效果通常更好但代码和损失函数都更绕毕设时间紧张时不推荐。先用两段式把流程跑通再决定要不要升级。我这里提一句如果你用的是 BERT 加一个 token 分类头只做抽取极性分类直接用整句[CLS]向量或抽取到的 aspect 对应 token 的向量接一个全连接都能做前者更简单。4. 模型选型与训练微调 BERT 的参数表和评测口径4.1 三条可用路线从零实现到中文预训练模型怎么选面向毕业设计模型选型不要追求新要追求“能跑通、能解释、能复现”。路线一自己从零用 PyTorch 写 LSTMCRF 做序列标注训练快、显存小但你得自己处理词向量、CRF 解码调参调到头秃效果还打不过预训练模型不推荐。路线二用bert-base-chinese微调一个 token 分类头这是最稳的主线中文评论场景效果好社区踩坑多遇到问题容易搜到答案。路线三如果你发现评论句子特别长、跨句信息多可以换 Longformer 中文模型这类长文本方案但显存和推理速度都要付出代价属于“有余力再升级”的选项不是默认选择。我的建议是默认bert-base-chinese跑通之后再花半天时间对比一个更大或更新的中文模型写进论文的“模型对比”一节就够了。对比不是越多越好答辩老师更在意你有没有能力解释“为什么这个模型在这个任务上好”。from transformers import AutoTokenizer, AutoModelForTokenClassification model_name bert-base-chinese num_labels 5 # O, B-Aspect, I-Aspect, B-Opinion, I-Opinion tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForTokenClassification.from_pretrained( model_name, num_labelsnum_labels, )这段代码里num_labels要和你的标签字典数量严格一致少一个多一个都会在训练时报 shape 不匹配。AutoModelForTokenClassification会自动在 BERT 后面接一个线性分类头你不用自己写 forward。第一次加载会从 Hugging Face 下载权重国内网络环境不稳的话提前把模型文件下载好放到本地目录from_pretrained(./bert-base-chinese/)走离线加载省去训练中突然断网的烦恼。4.2 训练参数五张必调参数表和一组保守默认值训练参数是最容易被当成玄学的东西其实每个参数都能找到调它的理由。下面是我在评论类文本上验证过的一组保守默认值。参数名默认值说明max_length128切句后绝大多数句子小于 128 字超过就截断batch_size88G 显存能跑16G 可调到 16learning_rate2e-5微调 BERT 用 1e-5 到 5e-5再大容易灾难性遗忘num_epochs3数据量 3000 句左右时 3 轮足够warmup_ratio0.1前 10% 步数线性预热防止大头冲撞weight_decay0.01对分类头做轻微正则batch_size直接影响显存但别为了凑大 batch 把max_length砍到 64句子被截断后方面词丢失模型 F1 会掉得莫名其妙。learning_rate是最值得花时间调的参数我见过很多人上来就5e-5训练 loss 降得很快验证 F1 却一直不动这就是学习率太大导致 BERT 底层表示被冲坏了。先用2e-5跑通再往上下各试一个量级把结果记到表格里、写进论文这就是实打实的“调参实验”。warmup_ratio一般不用动0.1是 huggingface 示例里的常见值改到0.2也不会带来质变。4.3 训练与保存用 Trainer 跑最小闭环用transformers.Trainer可以省掉自己写训练循环的时间代码量小且不容易出错。它会自动处理梯度累积、学习率调度、断点续训这些细节这对毕设来说是实实在在的省心。from transformers import ( Trainer, TrainingArguments, DataCollatorForTokenClassification, ) training_args TrainingArguments( output_dir./checkpoints, evaluation_strategyepoch, save_strategyepoch, learning_rate2e-5, per_device_train_batch_size8, per_device_eval_batch_size8, num_train_epochs3, warmup_ratio0.1, weight_decay0.01, logging_dir./logs, save_total_limit2, # 只保留最近两个 checkpoint load_best_model_at_endTrue, metric_for_best_modeleval_f1, fp16True, # 有 GPU 且显存紧张时开启 ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, data_collatorDataCollatorForTokenClassification(tokenizer), compute_metricscompute_metrics, # 4.4 节的评测函数 ) trainer.train() trainer.save_model(./final_model)save_total_limit2这个参数很多人忽略不加的话每轮存一个 checkpoint跑 5 轮就是 5 个模型每个几百 MB磁盘很快就满。fp16True能让显存占用降一半但必须有支持半精度的 GPU没有就关掉。load_best_model_at_endTrue会直接加载验证集最优的那一版而不是最后一轮这个对防止过拟合到训练末期很实用。训练中途断网或被杀进程trainer.train(resume_from_checkpointTrue)就能从最近 checkpoint 续上毕设周期长这个接口值得记住。4.4 评测不是看 accuracy实体级 F1 与极性 F1 怎么算序列标注任务的评测真正该看的是实体级 F1而不是逐 token 的准确率。举个例子模型把“风景很美”的“风景”标成“风”一个 token 加“景”一个 token如果按 token 算两个 token 都对了准确率 100%但实体“风景”的边界少了一个B-I衔接按实体算就是错的。所以要计算“预测出来的 aspect 实体”和“真实 aspect 实体”的重叠率用seqeval库是最省事的。from seqeval.metrics import classification_report, f1_score def compute_metrics(eval_pred): predictions, labels eval_pred predictions np.argmax(predictions, axis-1) # 去掉 -100 忽略位转回字符串标签 true_labels [[id2label[l] for l in label if l ! -100] for label in labels] pred_labels [ [id2label[p] for p, l in zip(pred, label) if l ! -100] for pred, label in zip(predictions, labels) ] return { f1: f1_score(true_labels, pred_labels), report: classification_report(true_labels, pred_labels), }这个函数里关键是-100的过滤如果不过滤[CLS]和[SEP]位置会被算进指标里把 F1 拉低。classification_report直接打印出每个标签的 P/R/F1写论文时截一张图就是现成的“实验结果分析”。注意这里的 F1 只衡量“抽取”这个子任务。极性分类的评测单独做先按实体匹配把预测出的 aspect 实体和真实 aspect 实体对齐能对上的才计算极性预测是否正确最后按方面类别分别汇总。只报一个总准确率会掩盖“卫生类预测得特别差”的真实情况答辩时被老师追问到类别细分就答不上来了。5. 避坑指南标注、切句、对齐与评测的五类翻车现场5.1 标注侧的三个坑坑一多字方面词被拆开标。现象模型预测时只输出B-Aspect很少输出I-Aspect导致“风景”永远只被识别出“风”字。原因标注员没理解 BIO 规则把“风景”两个字分别标成了两个独立方面词。解决在标注规范里写明“相邻且同类的词必须合并为一个 span”并在数据校验脚本里检查“是否存在I-Aspect前面不是B-Aspect”的序列出现就直接报错。坑二极性三分类不够用。现象强制把所有评论标成“正面”或“负面”测试集 F1 上不去因为“凑合”“还行”“一般般”本质上就是中性。原因标注时觉得中性是偷懒不愿标。解决给极性加第三个类别“中性”并把“一般”“凑合”“没感觉”写进规范例句里标中性不算错。五分类情绪强度对旅游评论是过度设计不建议上。坑三切句边界吞掉了方面词。现象原句“停车方便但贵”按标点切句后变成“停车方便”和“但贵”两句第二句里的“贵”失去了方面词“停车”。原因标点切句无法感知转折关系。解决5.1 里已经写了转折词并句的逻辑另外在预处理后加一个校验脚本统计“包含观点词但没有方面词的句子”数量如果超过 5%说明要么标注漏了、要么切句策略太粗暴。5.2 训练侧的两个翻车现场坑四标签和 BERT token 错位。现象训练 loss 正常下降验证时实体 F1 恰好是 0。原因我见过最典型的情况是用text.split( )按空格切词再标 BIO遇到中文没有空格标签全错位了。解决严格按 3.3 节的offset_mapping方案对齐并且在训练前抽一条样本把字符、token、label 三列打印出来人工核验。这一步不值得省。坑五随机切分验证集导致数据泄露。现象模型在验证集上的 F1 很高但实际输入新评论时表现很差。原因同一条评论被切成了多个句子随机切分时同一评论的句子一部分进了训练集、一部分进了验证集模型等于见过“答案”。解决按评论 IDsent_id的父级comment_id做分组切分用sklearn.model_selection.GroupShuffleSplit保证同一条评论的句子不跨集合。from sklearn.model_selection import GroupShuffleSplit # comment_ids 是每条样本所属的评论 ID splitter GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(splitter.split(samples, groupscomment_ids))test_size0.2是常规值random_state42固定下来保证每次跑实验切分一致不然今天跑的和明天跑的结果不同没法写进论文。5.3 评测侧的一个坑坑六只报 accuracy 被全班都喊不准。现象模型把所有评论都预测为“正面”整体准确率还有 65%看起来不错但每个类别细看负面样本的召回几乎为 0。原因旅游评论里正面样本天然多准确率指标被多数的“正面”带跑偏。解决所有结论都报告 macro-F1重点看“负面”类别自己的 P/R/F1并用sklearn.metrics.classification_report把每个类别单独打出来。这不算技巧是必须课。6. 从源码到答辩把模型变成能点开的交互页面训练好的模型如果只能跑命令行答辩演示会很吃亏。我一般用 Streamlit 把模型包成一个最简页面输入一句“风景很美但停车太贵”后端先做抽取把“风景”和“停车”标出来再分别判极性前端展示成表格直接在浏览器里点开给评委看。Streamlit 写起来比 Flask 省力不用管路由和模板。import streamlit as st from transformers import pipeline # 用训练好的抽取模型和分类模型搭两条 pipeline ner_model pipeline( token-classification, model./final_ner_model, tokenizer./final_ner_model, ) sent_model pipeline( text-classification, model./final_sent_model, tokenizer./final_sent_model, ) sentence st.text_input(输入一条景点评论, 风景很美但停车太贵) entities ner_model(sentence) st.write(抽取出的方面词, [e[word] for e in entities if Aspect in e[entity]]) result sent_model(sentence) st.write(整句极性, result)pipeline会把推理细节全部封装黑匣子味道很重所以页面里除了结果我会额外放一栏“模型认为的方面词原文片段”展示每个Aspect实体的start/end位置和置信度让评委能看到模型不是瞎猜的。答辩演示之前我习惯用 20 条新写评论做一轮盲测人工把结果分成“判断正确”“判断错误”“边界不全”三类统计一个粗略正确率写进论文的“局限性分析”里比单放 F1 值可信得多。如果用 Streamlit 部署在本地直接在终端跑streamlit run app.py就会弹出页面。答辩前一天一定要离线试一遍因为现场网络经常不稳定模型加载、页面渲染全要在本地完成不要赌现场网速。做这个项目时我吃过一次亏答辩前一晚才发现保存模型时只存了权重没存config.json加载直接报错只能临时现场下载网络一卡差点来不及。那之后我就养成了两个习惯模型目录里config.json、vocab.txt、权重文件一个不少复制模型时整套目录一起拷而不是只拷.bin文件。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑