做一个旅游评论情感分析项目,很多人的第一反应是"这不就是判断一条评论是好评还是差评嘛"。但真正接触过业务需求就会发现,光知道"这条评论整体是正面的"完全不够用。游客在一条评论里可以同时夸风景、骂排队、吐槽门票贵,你要的是"风景方面是正面的、排队管理方面是负面的、门票性价比方面是负面的"这种细粒度结论。这就是方面级情感分析(Aspect-Based Sentiment Analysis,ABSA)解决的核心问题。
这个项目是我在某段业务场景里做的一个完整闭环:从零构建了一套旅游景点领域的方面级情感分析语料库,同时训练了可落地的分析模型,源码和文档一并整理交付。整个项目涉及语料标注规范设计、模型选型、训练调参、推理优化四个大块,每一步都有可以复现的代码和踩坑记录。无论你是准备做NLP方向的毕设,还是做评论挖掘类业务功能,这篇文章都能给你提供一条可以直接照走的路线。
1. 项目整体设计与思路拆解
1.1 ABSA任务定义与旅游场景的特殊性
方面级情感分析在学术上通常拆成两个子任务:方面抽取(Aspect Term Extraction)和方面情感分类(Aspect Sentiment Classification)。前者负责从评论里找出游客具体在说什么对象,后者负责判断评论对该对象的情感极性。
放在旅游场景里,这两个子任务的形态会变得很具体。一条评论"昨天去了山上的观景台,视野确实开阔,但缆车排队快两个小时,工作人员态度也一般",里面同时涉及"观景台/视野"这个正面对象、"缆车排队"这个负面对象、"工作人员态度"这个负面对象。如果只做一个评论级的情感分类,这条评论大概率被标成中性或负面,很多有价值的信息就丢了。
旅游评论还有两个特殊难点。第一是隐含方面,评论里可能根本不出现具体名词,比如"人太多了,体验很不好",这里的"人太多"隐含的是"游客密度/排队体验"方面,模型需要从句意去推断而不只是做词典匹配。第二是方面权重不均衡,不同类型的景点(自然风景区、主题乐园、博物馆)关注的方面差异很大,一个刚建好的玻璃栈道景区可能90%的评论都在讲刺激感和安全性,传统通用领域的情感词表在这里基本失效。
1.2 项目目标与交付边界
动手之前我给自己定的目标是三个:语料可复用、模型可复现、代码可落地。
语料可复用,意思是标注规范要公开透明,别人拿到标注文档就能理解和延续标注工作,而不是只有一堆含义模糊的标签。模型可复现,意思是训练脚本要能从原始数据一路跑到指标输出,中间不需要人工干预。代码可落地,意思是训练好的模型不只是留在Notebook里演示精度,而是能封装成接口,在真实评论数据上跑出结构化结果。
这些目标直接决定了后续所有技术选型。比如我不追求单点指标刷到领域最先进水平,而是追求整套pipeline的稳定性和可解释性;不在模型结构上玩花活,而是用成熟框架把数据质量做到位。
1.3 技术路线选型的对比与取舍
我对比了三条技术路线,花费了不少心思:
路线一:两阶段pipeline方案,先做方面抽取再做情感分类,两个模型相对独立。优点是每个环节都能单独调试和评估,出错时容易定位;缺点是误差会累计,如果方面抽取漏了某个方面,后续情感分类就算再准也白搭。
路线二:端到端联合模型,用一个模型同时输出方面和情感。这类方案理论上可以利用两个任务之间的相关性互相增强,但实现复杂度高,训练起来也不稳定,而且中间表征无法直接检查,出了问题很难排查。
路线三:基于大模型的方案,把整条评论塞给生成式大模型,让它以结构化格式直接输出所有方面和情感。这个方案在小样本实验里效果其实不错,但当时面对两个现实约束:一是推理速度不足以支撑线上批量评论分析,二是成本高企,每万条评论的费用让人心疼。而且大模型的输出格式不稳定,偶尔出现标签缺失,后续纠错也麻烦。
最终我选择了路线一,基于BERT两阶段实现。理由很简单:这套方案的每个环节都有成熟的评估指标,便于我控制项目进度;中间产物(抽取出的方面词和方面类别)可以直接可视化给业务方看,沟通成本低;后续如果要换更强的主干模型,只需要替换特征提取器而不需要重写整个pipeline。
2. 语料库构建:标注规范与质量控制
2.1 数据采集与清洗策略
语料来源是公开的旅游平台评论数据,采集时只保留了不涉及个人信息的内容字段,并且做了去标识化处理。这里必须提醒一句:爬取公开评论数据时要关注平台的使用条款和数据合规性,我这边是在已有的授权数据集基础上做二次处理,并不是从某个具体平台硬爬。
原始评论大概收集了30000条,清洗后剩约12000条。清洗规则有几条,每一轮都在砍数据量,但非常有价值:
- 去掉纯颜文字和纯表情评论,这类样本没有完整的语义信息;
- 去掉长度小于10个字的评论,比如"好好好"三个字,标注和训练都容易出问题;
- 去掉明显是商家模板回复的内容,比如"感谢您的光临,我们会继续努力",这类内容没有真实情感;
- 对完全相同的文本做去重,有些用户会一键连发多条相同评论。
清洗完我刻意做了一个分层抽样,保证不同热度等级的景点在语料里都有覆盖。这个操作不少见,但很关键,如果语料全部来自一个网红景点,模型学到的词汇分布就非常偏。
2.2 方面类别体系设计
方面类别是这套语料的核心骨架,直接决定模型的输出空间。我没有参考通用情感分析体系直接照搬,而是自己先抽样看了500条评论,统计高频词,再结合旅游场景常识归纳出四大类十二小类的层级结构。
大类设计上,我当时定了四个:自然与景观、交通与设施、服务与管理、餐饮与住宿。大类下面的小类比如"自然与景观"拆成"景色观赏性""环境清洁度""天气与季节体验";"交通与设施"拆成"外部交通到达""景区内部接驳""排队等待体验"等。
这个设计当时经历了几轮迭代。一开始我分了六个大类,标注到一半发现"导游服务"和"讲解质量"标注员经常混在一起,不好区分,后来干脆合并成"服务与管理"大类下的"人员服务"小类。还有"性价比"这个方面,游客经常会说"门票不值这个价",但"值不值"很难界定边界,我后来把它归入"服务与管理"大类下单独的小类,只标记明确的性价比评价语句。
关于粒度的把握,我的原则是:宁可粗一点保证标注一致性,也不要为了炫技设计过细的类别,导致标注员吵成一团。最终十二个小类对于业务分析已经够用,每个类别在语料中都有足够的样本量支撑模型训练。
2.3 标注规约与一致性检验
标注规约是整个项目里最花时间的部分。我写了一份完整的标注文档,核心规约包括几大块:
显式方面和隐含方面怎么界定。显式方面指句中出现明确的词,比如"风景""缆车";隐含方面指句中没有直接对应名词,只能通过语义推断。规约里规定,隐含方面如果置信度较高就标,如果模棱两可就弃标,因为标错了反而误导模型。
情感极性一共四级:正、负、中、冲突。冲突指一条评论在同一方面上有正反两种态度,比如"景色很美但人多到崩溃",这句话在"景色"方面是正,在"排队体验"方面是负,但如果放在"整体体验"这个隐含方面上可能就是冲突。规约要求标注冲突时必须写出依据词,方便复核。
标注流程采用双人独立标注加仲裁机制。两个标注员分别标注,用一致性公式算出Kappa系数,低于阈值就重标。实操下来,前200条语料的Kappa只有0.62,主要问题是隐含方面和情感强度的判断差异。后来我们把规约细化,把容易争议的例子整理成"标注FAQ",第二轮Kappa就提到了0.81。
这部分我有个很深的体会:语料质量不是靠标注员自觉,而是靠标注规约的颗粒度。我们前后改了四版规约,每一版都是因为标注过程中出现了新的边界案例。最终交付的标注文档有将近40页,包含了每一类方面的定义、正例反例、争议场景说明,这套规范本身的价值已经不亚于标注数据。
3. 模型设计与核心实现
3.1 总体Pipeline架构
整个处理流程分三步:文本预处理、方面抽取、方面情感分类。第一步把原始评论切成标准格式;第二步从评论中抽取所有"方面词+方面类别";第三步逐一对每个抽取出的方面判断情感极性。
第一步预处理做得很朴素但很扎实,包括全角半角统一、繁体转简体、URL和数字串归一化,然后按句子切分。旅游评论普遍比较口语化,分词甚至不需要单独做,因为后面的模型用的是字级输入,不存在分词错误传播的问题。这是选BERT的一条隐形红利。
第二步方面抽取我用的是序列标注方案,标签体系是BIO格式,B表示方面词起始,I表示方面词内部,O表示非方面词。方面词的边界和类别直接做成联合标签,比如"B-风景""I-风景""B-服务"等。这样模型一次前向就能同时完成"找出方面词"和"判断方面类别"两件事。
第三步方面情感分类,输入的是"评论全文+方面词+方面类别",输出是该方面对应的情感极性。这里有个工程细节:同一个方面词在全文出现多次怎么办。我当时的处理是取该方面词在评论中第一次出现的位置作为锚点,然后把整句输入模型,靠注意力机制去定位对应的情感表达词。这个方案实测比单独截取窗口效果好,因为有些情感词跨句子出现,比如"虽然缆车排队很长,但是上面风景真的值了",情感判断需要看到后句才能反转。
3.2 方面抽取模型:BERT+BiLSTM+CRF
方面抽取的模型结构不是简单的BERT加全连接层,而是BERT+BiLSTM+CRF三层结构。BERT负责把每个字编码成上下文向量,BiLSTM在BERT输出基础上进一步建模序列依赖,CRF层负责在解码时保证标签序列的合法性,比如避免出现"B-风景后面直接跟O再跟I-风景"这样不符合BIO约束的序列。
这个结构对方面抽取来说非常适配。CRF层带来的约束看着不起眼,实际上能直接砍掉一批低级的标签顺序错误。做对比实验时,BERT+BiLSTM+CRF相比纯BERT+Softmax,在方面抽取F1上提升了大概1.8个百分点,主要提升就来自于消除了不合法的标签序列。
前向传播时,我做了两次实验对比:一是直接用BERT对每个字输出标签概率,二是在中间加一层CRF。第二次的效果更稳定,但代价是推理速度有所下降。考虑到后续要批量跑历史评论数据做分析,CRF带来的收益还是值得的。
3.3 方面情感分类模型:基于位置信息的注意力加权
方面情感分类我用的是BERT+位置编码的方案。具体来说,输入格式是:
[CLS] 评论全文 [SEP] 方面词 [SEP]
然后在BERT的隐层向量上,把对应方面词位置附近的token权重放大,再做池化分类。这么做的原因是:虽然"评论全文+方面词"两个segment的输入方式已经能让模型感知到"在分析哪个方面",但直接全量池化容易把噪声句子和真正表达情感的句子混在一起,位置加权可以强化方面上下文的信息。
位置加权的思路并不复杂:对输入中的每个token,计算它到方面词首次出现位置的相对距离,然后生成一个权重向量,距离越近权重越大。这个权重和BERT输出的隐向量逐元素相乘后再做平均池化,接一个三层MLP输出四个情感极性的概率分布。
实际测试中,这个方案比单纯用[SEP]分隔输入的分类模型F1高2个点左右。原因是旅游评论中情感词大量集中在方面词前后的一小段范围,显式的位置信息等于给模型提供了一个先验注意力分布。
3.4 训练细节与参数配置
训练阶段的超参数我是这样定的,基本沿用了中文BERT的标准配置:
预训练模型用的是中文BERT-base,序列最大长度128,batch size 16。学习率方面,BERT主干学习率设为2e-5,下层MLP学习率设为5e-5,用的是分层学习率策略。优化器是AdamW,warmup比例0.1,训练轮数方面抽取模型约4轮、情感分类模型约3轮,配合早停机制防止过拟合。
这里要特别提一下类别不平衡问题。旅游评论的情感极性分布天然不均衡,正面样例占了近六成,负面大约两成五,中性和冲突各占不到一成。如果直接按原始分布训练,模型会把所有样本都倾向预测为正面,表现在业务上就是"负面评论经常漏报"。
我的处理办法有三个,组合使用效果最好:
- 情感分类的损失函数改用Focal Loss,降低易分类样本的loss权重,让模型更关注难分的负面和中立样本;
- 训练时对负面样本做过采样,但比例控制在1.2倍以内,避免过分改变真实分布;
- 评估时坚持用宏平均F1而不是准确率,因为准确率在类别不均衡下太容易虚高。
最终评估结果:方面抽取的F1是87.2,方面情感分类的宏平均F1是83.5。两个数字单看都还不错,但组合成一个pipeline之后,端到端的准确率会打一些折扣,因为第二步的错误有一部分是由第一步的遗漏带来的。这个误差传播问题在后面章节会细说。
4. 实操过程:从数据预处理到模型部署
4.1 数据预处理代码结构
整个项目的代码组织结构按照"数据-模型-服务"三层拆分,源码里每个目录都有对应的README说明。核心预处理逻辑大概长这样:
import re from collections import Counter def clean_review(text: str) -> str: # 统一全半角、繁体转简体、URL/数字归一化 text = text.replace(",", ",").replace("。", ".").replace("!", "!").replace("?", "?") text = text.replace(" ", "") text = re.sub(r"(https?://)?\S+\.(com|cn|net|org)[^\s]*", "URL", text) text = re.sub(r"\d+", "NUM", text) return text def split_sentence(text: str) -> list: parts = re.split(r"[!?。;]", text) return [p.strip() for p in parts if len(p.strip()) > 1]切句的粒度可以看成是给模型准备的"专业内容"单元。旅游评论里一句话往往就是一个完整的"方面+情感"组合,把这个单元喂给模型,比直接整段喂更容易对齐标签。
标签编码我放在了独立的模块里,保证训练、验证、推理三段代码共用同一套映射表。这里有个教训:前期项目里有过一次训练和推理用了不同标签映射的情况,上线后模型输出乱码,排查了半天才找到原因。从那以后我把编码表和标签名都做了版本号管理,用单独的文件保存,谁都不敢拍脑袋改。
4.2 训练脚本与评估逻辑
训练脚本的设计目标是"一键复现",所以我把所有可调参数都抽到了配置文件里,包括模型路径、数据路径、学习率、epoch数、随机种子等。配置文件里刻意锁死了随机种子,目的就是让复现实验时能拿到一模一样的结果。
from transformers import AutoTokenizer, AutoModelForTokenClassification, Trainer, TrainingArguments tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") model = AutoModelForTokenClassification.from_pretrained("bert-base-chinese", num_labels=len(label2id)) training_args = TrainingArguments( output_dir="./checkpoints/aspect_extract", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=4, warmup_ratio=0.1, evaluation_strategy="epoch", save_strategy="epoch", logging_steps=50, seed=42, fp16=True, )评估代码里我做了一个比较得意的设计:除了常规的精确率、召回率、F1,还单独统计了"方面级混淆矩阵"。比如"餐饮"类别被错分成"服务"的概率是多少,"风景"方面被漏掉的句子有什么共性。这个矩阵看起来不起眼,但定位错误类型非常有用,后面做badcase分析全靠它。
4.3 模型推理封装与性能优化
训练完成的模型不能直接拿来用,我封装了一个分析服务类,对外暴露三个方法:抽取方面、判断情感、整体分析。整体分析内部串行调用前两个方法,直接返回结构化结果:
class TourismAbsaService: def __init__(self, extract_model_path: str, sentiment_model_path: str): self.extractor = AspectExtractor(extract_model_path) self.sentiment = SentimentClassifier(sentiment_model_path) def analyze(self, review: str) -> dict: aspects = self.extractor.extract(review) results = [] for aspect_word, aspect_type in aspects: polarity = self.sentiment.predict(review, aspect_word, aspect_type) results.append({ "aspect_word": aspect_word, "aspect_type": aspect_type, "polarity": polarity }) return {"review": review, "aspects": results}推理性能是踩过坑的。最开始直接用原生PyTorch跑,单卡GPU上一条评论大概耗时80毫秒,看着不多,但批量分析10万条历史评论就得好几个小时。后来做了两个优化:
- 把模型导出成ONNX格式,配合动态输入维度,单条推理耗时压到40毫秒左右;
- 推理时做了动态batch聚合,把多条评论按长度分组后一次性喂给模型,整体吞吐提升了接近一倍。
这里有个细节值得说:动态batch聚合不是简单地把所有评论直接塞进一个batch,而是按长度相近的分组,避免同一个batch里长短差异过大导致padding浪费。40毫秒的单条延迟用来做离线批量分析完全够用,如果将来要接实时接口,可以再考虑量化,但短期内没必要为了几毫秒牺牲精度。
5. 踩坑实录与问题排查技巧
5.1 标注不一致:花了最多时间的坑
项目前期最大的瓶颈不是模型,而是标注质量。两个标注员对同一条评论的标注结果,Kappa系数初期只有0.6左右,意味着有将近四成样本的标注是"有争议"的。排查后发现分歧主要集中在两类样本:一是隐含方面,标注员对"人多"到底算"排队体验"还是"游客管理"各执一词;二是多情感共存的句子,不同标注员对冲突情感的拆分粒度不一样。
解决方案是逐条梳理争议样本,把高频争议点写进标注规约的FAQ里,并且强制要求标注员每周做一次校准会议,只看最近一周标注结果中的分歧案例。这个方法听着很笨,效果却立竿见影,两周后Kappa就稳定在0.8以上了。
5.2 模型漏识别负面评论问题
第一次跑完情感分类模型,整体F1是0.79,看起来还行,但一拆类别就发现严重问题:负面样本的召回率只有0.58。这意味着四成左右的负面评论被模型漏掉了。
排查下来有两层原因。一是数据层面的负面样本占比太少,训练时模型见到的负面模式不足;二是情感表达的多样性,旅游评论里的负面表达经常是隐性的,比如"等了两个小时""门票涨了",没有强烈的负面情绪词,模型很难从字面上判断。
针对第一层原因,我用Focal Loss加过采样解决;针对第二层原因,我给训练数据加了位置信息辅助,模型学会关注"等待时间""价格变动"这类隐含负面指标词。两轮迭代之后,负面召回率提到了0.76,全局宏平均F1也升到了0.835。
5.3 长尾方面类别的冷启动问题
"天气与季节体验"这个类别在全部语料里只有两百多个样本,模型在这个类别上的F1一度低到0.42。这是典型的长尾类别冷启动问题,模型遇到过少,几乎学不到稳定的模式。
我的处理办法是回填数据:从原始30万条未标注评论里先用模型粗筛一遍,把包含"天气""下雨""晴天"等关键词的评论捞出来,再由人工快速标注,总共补了两千多条样本。这个办法的代价是增加了大约三天的标注工作量,但换来了该类别F1从0.42稳定提升到0.78,性价比很高。
5.4 端到端误差传播的量化分析
pipeline方案必然存在误差传播,但具体传播多少,我做了量化:方面抽取单步F1是0.872,情感分类单步宏F1是0.835,理论上端到端应该有0.73左右。实际端到端F1是0.69,差了将近4个点。这4个点主要来自于"方面抽取漏抽导致情感分类无米下锅"和"方面抽取抽错类别导致情感分类判断错对象"两类错误。
解决误差传播我试过两个方向。一个方向是训练一个端到端的纠错模块,对pipeline输出做二次校验,但收益不明显还引入了新的复杂度。另一个方向是提升第一步精度,重点优化方面抽取的边界识别——就是把"风景"这种一个词的方面词给准确定界,别把"风景很漂亮但是"一起抽出来。后者的效果更直接,最终端到端F1提升到了0.72,已经接近理论值。
6. 可扩展方向与个人体会
6.1 从预训练模型到大模型的衔接思路
现在回看这个项目,一个很自然的后续方向是利用大模型做语料增强或模型蒸馏。大模型的强项在于少样本理解和复杂情感推断,可以直接用它对低置信度样本做二次标注,也可以把它生成的伪标签作为监督信号来训练小模型。我建议的做法是:保留现在两阶段pipeline作为推理主框架,用大模型做离线的困难样本挖掘和标签修正,这样既能提升语料质量,又不用承担大模型的推理成本。
6.2 跨景点类型迁移时要注意什么
不同景点类型的评论分布差异很大,把自然风景区训练好的模型直接迁到主题乐园上,效果会打折扣。如果要做迁移,至少需要做两件事:一是对比目标领域语料和源领域语料的方面分布,必要时扩充类别体系;二是用少量目标领域数据做微调,迁移学习需求几百条数据就能有明显改善。这方面值得进一步展开做跨领域实验。
6.3 我的几点实际体会
这个项目做下来,最大的体会就一句话:方面级情感分析项目,80%的成败在数据,20%在模型。模型结构再先进,也扛不住标注混乱、类别定义不清、样本分布畸形的数据。所以如果让我重来一次,我会把更多时间花在标注规约设计和质量校验上,而不是反复调整模型结构。
第二个体会是,两阶段pipeline虽然被很多论文说成"过时方案",但它在工程落地的时候依然表现稳定,因为每一步都能检查、能干预、能量化损失。对要做实际业务的人来说,稳定可维护比单点指标好看重要得多。
最后给准备复现这个项目的同学一个建议:不要一上来就追求大而全的所有类别体系,先定四个大类、跑通整套流程,后面再迭代增加细分类别。先活下去,再优化,这是所有NLP项目通用的原则。
另外还有一个小技巧想分享一下,对于评论中频繁出现的同一方面词不同表达,比如"景色""风景""view",在做数据预处理时可以把它们人工归并成同一组候选项,这样对后面的情感分类是很有帮助的,因为模型不需要再从零学习"这些词其实说的是同一个方面"。这个小操作看着不起眼,实测下来能把个别类别的F1提高1到2个点,而且几乎不增加成本。