
简介这是一个基于Python旅游景点方面级别情感分析的毕业设计完整实现采用Django框架、MySQL数据库与RNCC模型可作为计算机专业学生完成毕业设计或入门情感分析系统开发的参考项目。资源以zip压缩包形式提供大小约81.99MB包含完整源码、数据库文件与演示视频覆盖语料库构建、文本管理、文本分类等核心模块。系统首页展示用户数量、累计评论数、已标注与未标注评论数的统计信息并通过柱状图呈现好评、中评、差评的分布情况文本列表界面支持查看与管理爬取的用户评价可对文本进行标注和删除文本分类模式允许直接输入文本由模型自动判定情感倾向例如输入“景区一点都不好玩”系统会将其识别为消极评价并以窗口形式反馈分类结果。资源完整度较高源码与数据库均可直接运行演示视频有助于快速理解系统功能与操作流程目前已有193人学习使用适合需要快速搭建情感分析语料库系统或借鉴答辩演示思路的开发者。1. 旅游方面级别情感分析为什么酒店的「位置好」和「隔音差」不该打成同一个分打开美团或携程你会发现同一个景区底下有人夸「交通便利、离地铁口50米」也有人骂「隔音差、半夜走廊脚步声不断」。这两种评论如果都按整句话打分模型永远学不会「位置」这个方面到底是被夸还是被骂。方面级别情感分析Aspect-Based Sentiment Analysis要做的就是先抽方面——交通、餐饮、卫生、服务、性价比、景观再判断每个方面各自的情感极性而不是给整条评论一个笼统分。这类选题是毕业设计里的「保值款」它有一套完整的链路——语料库怎么建、模型怎么选、数据库怎么存三个东西凑齐就是一套能演示的完整系统而且每一块都有真实场景撑着。本文按我实际做过的技术方案往下拆覆盖从标注规范、模型微调到数据库读写和踩坑记录。源码网上能搜到不少带数据库和演示视频的模板但那个是「别人的」下面这份是「自己能跑起来、答辩问到细节也不虚」的路线。2. 语料库先行方面级标注的维度设计与格式转换2.1 为什么要自建语料库而不是直接拿公开数据集方面级别情感分析有几个公开中文数据集比如CCTS或SemEval的中文部分但这些数据主要来自餐饮和数码领域旅游景点里的「排队时长」「索道安全性」「夜景灯光」这些具体方面覆盖很少。旅游评论有很强的领域性——「冷」在温泉是正面在山顶是负面「人多」在美食街和博物馆的评价逻辑也完全不同。所以自己建一个景点评论语料库是本课题最容易拿分、也最容易被忽视的工作。构建流程我一般分三步走采集原始评论、清洗、标注。采集阶段从主流OTA平台抓取评论注意只取带景区名称、打分和评论文本的字段保留JSON原始结构。清洗阶段主要做去重和去广告标注阶段是重头戏需要制定一个标注规范文档规定好每个方面对应的情感极性值。2.2 标注维度与标签体系怎么定我建议标签体系做成两级方面类别(Aspect Category)加情感极性(Sentiment Polarity)。方面类别是固定的几个封闭集合——交通、餐饮、卫生、服务、性价比、景观、安全、拥挤程度不要用开放式NER去抽否则标注一致性根本没法保证。情感极性用三档1正向、0中性、-1负向。每条标注记录形如下面这段JSON{ comment_id: C10001, text: 索道排队四十分钟但山顶风景确实值得就是厕所卫生太差。, aspects: [ {category: 拥挤程度, polarity: -1, trigger_words: [排队四十分钟]}, {category: 景观, polarity: 1, trigger_words: [风景确实值得]}, {category: 卫生, polarity: -1, trigger_words: [厕所卫生太差]} ] }逻辑说明trigger_words是指向原文中能证明该极性判断的片段这一步对后续训练模型做可解释性非常关键答辩时老师常问「你怎么证明模型学到了方面与情感的对齐关系」trigger_words 就是你的证据。category用封闭集合是为了避免标注员自由发挥同时方便后面做分类头的输出维度。参数说明每个方面对应一个trigger_words切片一个句子允许同时出现多个方面——这是方面级与句子级情感分类的本质差异。实际标注时建议两人独立标注然后用Cohens Kappa系数算一致性一般要冲到0.7以上才说明标注规范可信。2.3 从原始文本到训练集转换脚本与统计分析标注完成后要生成模型训练可读的格式。最省事的方式是转成HuggingFace的Dataset格式也就是一个JSON列表。下面这个脚本把标注好的JSON文件转成带input_ids的最终训练集并输出类别分布统计import json from collections import Counter from transformers import AutoTokenizer tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) def convert_to_train_format(raw_path, out_path, max_len256): samples [] aspect_counter Counter() with open(raw_path, r, encodingutf-8) as f: for line in f: data json.loads(line) text data[text] fragments [] for asp in data[aspects]: frag { category: asp[category], polarity: asp[polarity] 1, # 0/1/2 便于 softmax } fragments.append(frag) samples.append({ text: text, fragments: fragments }) for asp in data[aspects]: aspect_counter[asp[category]] 1 with open(out_path, w, encodingutf-8) as f: json.dump(samples, f, ensure_asciiFalse, indent2) total sum(aspect_counter.values()) for k, v in aspect_counter.items(): print(f{k}: {v} ({v/total:.2%})) convert_to_train_format(data/raw_annotated.json, data/train_format.json) print(转换完成)逻辑说明polarity 1是把 -1/0/1 映射到 0/1/2这样可以直接用 CrossEntropyLoss。这里没有直接生成input_ids原因是在训练脚本里统一做tokenize避免转换时切词导致文本与方面标签错位。max_len256是考虑到BERT-wwm的position embedding上限后面要硬调更长文本再单独说。参数说明这个脚本的raw_path是标注工具导出的逐行JSON如果标注记录是Excel或CSV需要先转成每行一条评论的JSON。打印分布的目的是检查样本不平衡——根据我做过的一个小型旅游景点数据集最常见的方面是「交通」和「拥挤程度」最少的是「安全」后者标注样本只有几十条训练时极易被模型忽略。3. 模型选型与微调Longformer中文模型与轻量兜底方案3.1 为什么基线与Longformer之间差了两代方案方面级情感分析最经典的基线是BiLSTMAttention把句子过一遍词向量后对每个方面类别分别做一个attention最后接全连接做三分类。这个方案的好处是显存开销小、可解释性强但有两个致命问题一是没有预训练语义对「性价比不高但胜在干净」这种转折句理解不好二是句子过长时LSTM的长期依赖会衰减。所以现在的常见做法是用中文预训练模型做encoder先跑hfl/chinese-roberta-wwm-ext作为强基线再上Longformer中文模型。Longformer的稀疏注意力机制把复杂度从 O(n^2) 压到 O(n)对旅行评论这种动不动几百字的文本非常有必要。BERT-wwm的512 token上限在真实旅游评论里往往要把「索道排队」细节和「山顶风景」放在同一个窗口才能对齐方面截断后模型常常只看到抱怨看不到表扬。3.2 用PyTorch微调Longformer的核心代码import torch from torch.utils.data import DataLoader, Dataset from transformers import LongformerTokenizer, LongformerForSequenceClassification, AdamW class AspectDataset(Dataset): def __init__(self, data, tokenizer, max_len512): self.data data self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.data) def __getitem__(self, idx): item self.data[idx] text item[text] # 把方面类别连上原文在输入侧显式注入方面信息 # 例如 [卫生] text让模型知道当前要判断的方面是什么 encoded self.tokenizer( text, truncationTrue, paddingmax_length, max_lengthself.max_len, return_tensorspt ) polarities item[fragments] return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), labels: torch.tensor(polarities[0][polarity], dtypetorch.long) } model LongformerForSequenceClassification.from_pretrained( schen/longformer-chinese-base, num_labels3 ) # Longformer 必须设置全局注意力通常把句子首尾或CLS位置设为全局 model.config.attention_window [256] * 12 train_dataset AspectDataset(torch.load(data/train_format.pt), tokenizer) train_loader DataLoader(train_dataset, batch_size4, shuffleTrue) optimizer AdamW(model.parameters(), lr2e-5) for epoch in range(3): model.train() for batch in train_loader: optimizer.zero_grad() outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], labelsbatch[labels] ) loss outputs.loss loss.backward() optimizer.step()逻辑说明上面的模型是LongformerForSequenceClassification用了schen/longformer-chinese-base这个中文权重。attention_window[256]*12表示每个Transformer层的局部窗口大小是256窗口之外的token不互相直接attend这就是Longformer相较于BERT在长文本上省显存的关键。polarities[0]这里一次性只取第一个方面做演示实际训练时一个样本通常要复制成多个「文本方面」对每个对给一个情感标签——这是方面级情感分析最常见的训练范式。参数说明batch_size4在单张12GB显卡上比较稳如果换成batch_size8很容易把longformer-chinese-base的显存跑爆因为它的模型参数量比BERT-large还大。lr2e-5是预训练模型微调的标准起点不要用1e-3否则几个step后loss直接飞掉。3.3 预算不够时的兜底方案词典打分与规则对照如果实验室没有显卡或者答辩演示机器没有GPU必须有一个本地可跑的词典兜底方案。这个方案精度不如Longformer但足够给评委演示「模型对比实验」的baseline。核心思路是维护一个方面词典比如「卫生」这个方面对应「脏」「乱」「臭」「干净」「整洁」每个词带一个情感分数aspect_lexicons { 卫生: {干净: 1, 整洁: 1, 舒适: 1, 臭: -1, 脏: -1, 乱: -1}, 交通: {便利: 1, 地铁: 1, 偏远: -1, 难找: -1, 堵: -1}, 拥挤程度: {人多: -1, 排队: -1, 清净: 1, 空旷: 1} } def lexicon_predict(text, aspect): score 0 hits 0 for word, polar in aspect_lexicons.get(aspect, {}).items(): if word in text: score polar hits 1 if hits 0: return 0 # 中性兜底 return 1 if score 0 else (-1 if score 0 else 0)逻辑说明这个函数对一条评论里的单个方面做词典打分统计命中的正向词和负向词数量最后汇总成一个极性。它是纯规则模型没有泛化能力但可以用它生成一个伪标注集来扩充训练数据——其实就是半监督自训练的思路词典模型先给未标注评论打伪标签置信度高的再进训练集。参数说明词典的覆盖面完全决定这个方案的效果常见做法是从训练集里跑一遍Word2Vec抽出每个方面类别的Top 20相似词做扩充再把明显反向的词手工修正。即便如此词典打法一般只能跑到0.55左右的F1Longformer能跑到0.78以上中间这0.2的差距就是模型的核心价值。4. 数据库设计与MySQL增删改查从评论原文到预测结果全链路4.1 表结构设计的四个关键点毕业设计里数据库这一块很多同学就是建一张表存评论、一张表存结果用一个自己的评测模型会是30分和80分的差别。我建议至少分四张表scenic_spot存景点信息comment_raw存原始评论aspect_annotation存人工标注结果model_prediction存模型的预测输出。这样设计的原因是——答辩时老师一定会问「你存预测结果做什么」答案就是「去算模型上线后的漂移追踪错误样本」。CREATE TABLE scenic_spot ( spot_id INT PRIMARY KEY AUTO_INCREMENT, spot_name VARCHAR(100) NOT NULL, city VARCHAR(50), level VARCHAR(10) ); CREATE TABLE comment_raw ( comment_id INT PRIMARY KEY AUTO_INCREMENT, spot_id INT NOT NULL, user_rating TINYINT, comment_text TEXT NOT NULL, created_at DATETIME, FOREIGN KEY (spot_id) REFERENCES scenic_spot(spot_id) ); CREATE TABLE aspect_annotation ( annotation_id INT PRIMARY KEY AUTO_INCREMENT, comment_id INT NOT NULL, aspect_category VARCHAR(20) NOT NULL, polarity TINYINT NOT NULL, annotator VARCHAR(50), FOREIGN KEY (comment_id) REFERENCES comment_raw(comment_id) ); CREATE TABLE model_prediction ( prediction_id INT PRIMARY KEY AUTO_INCREMENT, comment_id INT NOT NULL, aspect_category VARCHAR(20) NOT NULL, predicted_polarity TINYINT NOT NULL, confidence FLOAT, model_version VARCHAR(20), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );逻辑说明comment_raw和aspect_annotation是一对多关系一条评论对应多条方面标注这是本课题数据库设计的核心范式。model_prediction里的model_version字段非常实用因为训练过程中会迭代多个模型没有版本号的话后面完全无法对比「这个错误到底是哪个模型引入的」。user_rating保留原始星级评分可以在后续做「星级评分 vs 方面极性」的一致性分析——评4星的评论里有没有提到交通差这个交叉分析是演示视频里的亮点模块。4.2 Python MySQL 增删改查的最小可跑代码import pymysql conn pymysql.connect( hostlocalhost, userroot, passwordyour_password, databasetravel_absa, charsetutf8mb4 ) def insert_comment(spot_id, user_rating, comment_text): sql INSERT INTO comment_raw (spot_id, user_rating, comment_text) VALUES (%s, %s, %s) with conn.cursor() as cursor: cursor.execute(sql, (spot_id, user_rating, comment_text)) conn.commit() return cursor.lastrowid def fetch_comments_by_aspect(aspect_category, limit10): sql SELECT cr.comment_text, aa.polarity FROM aspect_annotation aa JOIN comment_raw cr ON aa.comment_id cr.comment_id WHERE aa.aspect_category %s ORDER BY aa.polarity DESC LIMIT %s with conn.cursor() as cursor: cursor.execute(sql, (aspect_category, limit)) return cursor.fetchall() def update_prediction_confidence(prediction_id, new_confidence): sql UPDATE model_prediction SET confidence %s WHERE prediction_id %s with conn.cursor() as cursor: cursor.execute(sql, (new_confidence, prediction_id)) conn.commit()逻辑说明fetch_comments_by_aspect这一条SQL JOIN了标注表和原始表按情感极性倒序取评论这是演示时最常用的接口——「把卫生差评最多的评论列出来」。charsetutf8mb4在表创建时也必须同步否则评论里的表情符号写入时会报错。参数说明lastrowid获取插入后的自增主键方便在插入评论后立刻把标注和评论关联起来。limit10只是示例实际参数应按接口调用方传入。注意这里每次操作都手动commit()在Django或Flask里一般用事务装饰器统一提交但毕业设计演示单脚本跑通就够。4.3 用SQLite做轻量替代的边界如果MySQL在本机装起来费劲可以先用SQLite把流程跑通。SQLite支持几乎相同的SQL语法唯一要改的是连接方式import sqlite3 conn sqlite3.connect(travel_absa.db)逻辑说明把pymysql.connect(...)换成sqlite3.connect(...)即可原表结构不用改。SQLite适合本机演示和代码调试但如果你要演示「并发插入1000条评论」这种压力场景SQLite是撑不住的MySQL才是标配。我的建议是——开发阶段用MySQL从第一天开始数据库这块不值得来回迁移。5. 踩坑记录关于标注不一致、长文本截断、类别不平衡与乱码5.1 标注不一致Kappa低于0.6时模型学到的全是噪音现象两个标注员对「门票偏贵但学生半价很划算」这句话一个认为性价比是负向另一个认为是正向同一批数据训练出来的模型F1在0.45左右上不去。原因方面级情感判断本身有主观性尤其「贵但划算」这类转折句标注规范里没有规定好「以实际支付价格还是以标价为基准」时标注员会自由发挥标注一致性崩塌模型学的是两份互相矛盾的金标准。解决在标注规范里追加一条规则——「若文本中同时出现正向与负向触发词极性以最终句子的倾向为准且必须注明trigger_words」然后重新让标注员对争议样本讨论并合并。实战中我一般先跑Kappa系数低于0.6就回炉重标不要急着进训练高于0.7才继续。5.2 Longformer截断后仍丢方面信息现象用Longformer跑一条「从市区开了两个多小时才到路很难走但到了之后发现景区里面的栈道修得特别好」的评论输出结果「交通」为中性与实际明显不符。原因虽然Longformer支持4096长度但schen/longformer-chinese-base的tokenizer在truncationTrue时默认截断位置是512或者4096评论前半段详细抱怨路程的信息被覆盖了模型只看到后半段对栈道的夸赞另一方面注意力窗口设得太大全局注意力没有覆盖到「交通」的相关词。解决给Longformer输入时显式把方面词放在输入开头比如构造「[交通] 从市区开了两个多小时才到……」并把attention_window调小到128强制模型在窗口中关注局部细节。另外一条更省事的路径是把评论在「但」字位置切分成两个片段分别做方面预测再合并规则。5.3 类别严重不平衡安全、拥挤程度两个类别F1极低现象训练集里「安全」只有40条样本「交通」有1200条模型对「安全」的预测几乎全部落在中性F1不到0.2。原因神经网络天生偏向多数类「安全」在训练中出现的次数太少模型压根不更新这个方向的分类边界。加上「安全」方面的文本通常表述隐晦比如「护栏有点矮」「台阶太滑」词典方案也覆盖不到。解决第一个方案对少数类做过采样——把「安全」样本复制3倍加入训练第二个方案在损失函数上给每个类别加权重CrossEntropyLoss(weighttorch.tensor([1.0, 1.0, 2.5]))负类的权重加大强制模型关注错判少数的代价第三个方案最有效——去OTA平台专门爬「有小朋友或老人出游」的评论这类评论提安全的概率远高于平均水平直接补齐样本来源让模型有东西可学。5.4 数据库乱码与emoji截断现象评论库里带表情的记录写入MySQL时报Incorrect string value错误查出来的繁体字也乱码。原因MySQL老版本表的默认字符集是utf8它是MySQL的utf8mb3只支持3字节编码而emoji是4字节此外Python连接串里漏掉charsetutf8mb4也会复现同样的问题。解决建库时直接指定DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci连接时同样加上charsetutf8mb4。对于已经建错的表一条ALTER语句就能救回ALTER TABLE comment_raw CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;逻辑说明CONVERT TO CHARACTER SET会同时改变列类型和已存在数据的存储编码但需要在这个操作前备份数据尤其是评论表里有长文本时转换失败容易丢数据。乱码这类问题的排查非常简单插入后先查一条看到问号就立刻检查连接串和表字符集不要拖到演示前才查。5.5 滑动窗口滤波的启发预测结果序列化平滑处理现象同一景点的不同评论之间模型对「卫生」的预测一会儿正一会儿负肉眼看不规律说明模型不稳定。原因模型对单条短文本的判断方差大表达模糊的评论稍微改几个字就可能翻转预测结果评测时这种抖动会直接反映在F1波动上。解决如果你的演示系统是按「景点」聚合输出方面情感的可以对同一个景点的所有预测结果做一个滑动窗口滤波本质上就是按时间或评论ID排序后取最近N条的平均极性from collections import deque def smooth_predictions(pred_list, window5): q deque(maxlenwindow) smoothed [] for pred in pred_list: q.append(pred[polarity]) smoothed.append(sum(q) / len(q)) return smoothed逻辑说明这是一个最简单的移动平均window5表示取最近5条评论的极性均值结果变成0到1之间的连续值从而得到一个「最近卫生趋势」曲线。这个曲线比单条预测稳得多而且适合在演示视频里作为图表展示。本质上是把模型输出做了时间维度上的平滑对抗单条噪声样本的扰动。毕业答辩演示时评委看到这条曲线比看到一堆精确到0.001的几个数值要直观得多也能说明你不只是调了个API而是把模型的输出真正用起来了。6. 验证模型能不能上线一组实用手工评测与错误样本归因训练完成后别急着贴指标先用最笨但最可靠的方式验证一遍——人工盲测。我通常会随机抽20条测试集评论让没参与标注的同学按「方面-极性」标注一遍然后拿模型预测结果对撞。这个动作的意义在于你从数据里算出的F1是基于同一标注规范的而新标注人没有参与过你们的内部规范讨论如果模型在新标注人的结果上表现也稳定说明泛化能力站得住不是只记住了训练集的标注口味。错误样本的归因分析是容易被忽视但很出彩的一步。跑完测试集后把所有预测错误的样本按错误类型分桶有「方面识别错」即模型把「停车」归到交通而不是服务有「极性极性倒置」即模型把「不贵」识别为负向这类通常让模型吃亏的是否定词与情感词的结合。分类统计后你就会发现模型到底在什么场景下崩——比如长文本里前部出现「免费」后部出现「但是排队太长」模型常常对「性价比」给正对「拥挤」也给正这种错误规律完全可以在演示视频里作为案例分析展示。如果想让整个方案再往前推一步可以试试在训练时把星级评分当成辅助标签用多任务学习把user_rating4作为全局情感信号与方面级极性一起预测。4星评论里大概率没有负向的「卫生」触发词这个全局约束能让模型在训练过程中少学一些反直觉的组合F1通常能额外涨23个点。这是我做类似情感分析项目时常用的习惯——不要只盯着单一任务的目标函数数据里自带的元信息基本都是免费的正则化信号。最后想强调一句毕业设计的价值不在模型AUC涨了多少而在于你能否从语料库、模型、数据库到验证讲出一条完整的链路。每个环节留一两个可复现的脚本和一个肉眼可见的排查案例比堆砌模型结构更打动人。这个方向做下来你会发现方面级别情感分析是少数几个「做完之后真能感觉到模型在变聪明」的自然语言处理题目——从词典打分到Longformer每一步的提升都是看得见摸得着的。希望这些踩坑记录和实现细节能帮到你祝你的答辩顺利。本文还有配套的精品资源点击获取