尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

旅游景点方面级情感分析实战:从语料标注到BERT微调

旅游景点方面级情感分析实战:从语料标注到BERT微调 简介这是一套面向旅游景点评论的方面级别情感分析Python项目资源涵盖完整工程代码、已标注语料库与预训练模型主要运用于课程设计、毕业设计以及NLP入门实践。压缩包约105.9MB内部以Python源码、标注好的景点评论语料、模型权重文件及系统说明文档为主代码模块包括文本预处理、分词、特征提取、SVM/随机森林/RNN等模型训练与测试流程便于对照学习。目前已有124人学习该资源适合具备一定Python基础、希望快速搭建情感分析系统并开展评测的开发者。项目完整覆盖从语料标注、特征工程到模型训练与情感预测的实现链条既可用于游客满意度分析和市场舆情监测也可作为验证情感分析算法性能的实验基准对课程设计或科研入门都颇有帮助。1. 旅游景点方面级情感分析为什么一句“很壮观但人太多”会难住普通情感模型“故宫很壮观但人太多了洗手间排队特别久。”这大概是景区点评里最典型的一句话。它里面至少有三个被评价的对象——景观、人流量、洗手间而对应的情感分别是正向、负向、负向。普通的句子级情感分析模型会把整句打成中性或正负混合然后丢掉信息方面级别情感分析Aspect-Based Sentiment AnalysisABSA要解决的就是这件事把评论里每个被评价的“方面”找出来再分别判断它的情感极性。这个 zip 里装的是一套专门为旅游景点场景准备的语料库与模型解压后可以直接跑通“评论进来方面加情感出去”的管道也可以用自己的点评数据继续微调。适合三类人做景区舆情或 OTA 点评分析的工程师、刚入门 NLP 想落地真实文本项目的开发以及被“标注数据贵”卡住的算法团队。2. 语料库是怎么组织的读懂标注格式才能用好 zip 里的训练数据拿到压缩包先别急着解压跑模型先打开语料文件看标注。很多人在 ABSA 项目上翻车不是因为模型不会调而是语料格式没看懂就开训最后标签对不上、性能稀烂还不知道原因。ABSA 虽然看起来只是“情感分类”但它多了“确定评价对象”这一步所以语料格式直接决定模型能不能学会“方面”这个概念。2.1 ABSA 语料的核心单元aspect、opinion、polarity 三元组标准 ABSA 语料里一条评论不是只打一个情感标签而是拆成若干个三元组评价对象aspect、观点词opinion、情感极性polarity。拿开头那句评论举例会得到三个三元组aspectopinionpolarity故宫壮观POS人流量太多NEG洗手间排队很久NEG有了这个结构模型要做两件事第一从评论中找出“故宫”“人流量”“洗手间”这些方面词第二对每个方面词判断情感是正、负还是中性。这就是方面级别情感分析和普通情感分类的本质区别。不过很多工程落地项目为了少维护两个模型会把 polarity 直接编码进 token 标签用一套 BIOES 复合标签同时输出“边界 极性”。例如把“人流量太多”中的“人流量”标成B-NEG、E-NEG或者干脆把“人太多”整体作为一个 span 标成B-NEG、I-NEG、E-NEG。两种做法都能训练关键是解压后先看 zip 里的标注规范用的是哪一种然后统一成一种别混着用。2.2 一条样本长什么样JSON 结构与 labels 字段常见做法是把语料组织成 JSON 数组每条样本包含原始文本、按词切分的 tokens、与 tokens 等长的复合标签以及可选的三元组列表。下面是我处理这类项目时惯用的结构{ text: 故宫很壮观但人太多洗手间排队很久。, tokens: [故宫, 很, 壮观, , 但, 人, 太, 多, , 洗手间, 排队, 很, 久, 。], labels: [B-POS, I-POS, O, O, O, B-NEG, I-NEG, E-NEG, O, B-NEG, I-NEG, I-NEG, E-NEG, O], triplets: [ {aspect: 故宫, opinion: 壮观, polarity: POS}, {aspect: 人太多, opinion: 太多, polarity: NEG}, {aspect: 洗手间排队, opinion: 排队很久, polarity: NEG} ] }注意这里的labels用的是复合标签方案B-POS表示正向方面词的开始I-POS表示中间字E-NEG表示负向方面词的结束O表示非方面词。labels 的长度必须和 tokens 完全一致否则后面做模型对齐时会出现诡异的错位而且这种错位很难一眼看出来。2.3 动手体检语料一个 python 脚本识别标注不一致和空白标签在训练之前我会先跑一个数据体检脚本。它不复杂但能拦住大多数低级错误token 与 label 长度不一致、出现未知标签、两个B-开头标签紧挨着说明前面一个方面词的 span 没闭合。import json from collections import Counter LABEL_SET { O, B-POS, I-POS, E-POS, S-POS, B-NEG, I-NEG, E-NEG, S-NEG, B-NEU, I-NEU, E-NEU, S-NEU, } def inspect_corpus(path): with open(path, r, encodingutf-8) as f: data json.load(f) label_counter Counter() problems [] for idx, item in enumerate(data): tokens item.get(tokens, []) labels item.get(labels, []) if len(tokens) ! len(labels): problems.append((idx, len_mismatch, len(tokens), len(labels))) prev_label None for pos, lab in enumerate(labels): if lab not in LABEL_SET: problems.append((idx, unknown_label, pos, lab)) label_counter[lab] 1 if lab.startswith(B-) and prev_label and prev_label.startswith(B-): problems.append((idx, B_B_conflict, pos, lab)) prev_label lab return label_counter, problems if __name__ __main__: counter, problems inspect_corpus(corpus.json) print(counter) for p in problems[:20]: print(p)逻辑说明label_counter统计每个标签的出现次数你可以直观看到哪个极性类别样本太少problems收集具体问题len_mismatch是 tokens 和 labels 长度不一致unknown_label是标签集外字符B_B_conflict是会破坏 BIOES 规则的两个相邻 B 标签。参数说明这个脚本只读一个corpus.json路径如果你 zip 里是 CSV 或 txt 格式先转成 JSON 再跑不要直接改脚本硬凑。2.4 从语料标签到模型输入tokenizer 对齐的前置准备把手工标注的 token 标签变成模型能吃的 label中间有一个容易被忽略的环节tokenizer 切词方式。中文 BERT 基本按字切分所以“故宫”可能被切成故、宫两个字一个 token 标签就要变成两个字级别的标签原本的B-POS后面得跟着I-POS才能保持 BIOES 规则闭合。如果用 jieba 这类词级分词器预先分好词再塞给 BERT 的 tokenizerword_ids 对应关系会乱掉这就是后面第 5 章要说的常见翻车点。在进入模型训练前最好先用 tokenizer 把几条样本跑一遍打印出 word_ids 和标签的对应关系确认对齐逻辑没问题再全体转换。3. 模型选型与训练把 zip 里的模型权重变成你的 ABSA 服务语料格式看懂了下一步就是选模型和训练。很多新手一上来就问“要不要用 LSTM”或者看到微调就想去试各种大模型。对旅游景点点评这种数据量在几百到几千条的领域项目来说选型的第一原则是稳定、可复现而不是堆参数。3.1 为什么默认方案是“中文 BERT Token 分类”而不是 LSTMCRFLSTMCRF 在序列标注里确实是经典方案数据量小的时候也能跑但它有两个问题一是需要额外的中文分词分词错误会直接传导到标注二是 LSTM 对上下文建模能力有限遇到“位置很好但隔音差”这类转折句容易只记住局部词的情感。中文 BERT 基本按字切分天然避开了分词错误微调成本也不高。网上一说到大模型就有人安利 deberta、longformer 中文模型但对这种短文本景点语料bert-base-chinese往往是预算和效果最平衡的起点。如果评论特别长、超过 512 个 token再考虑 longformer 或者滑窗切片大部分景区点评都在几十到一百多字用不上。3.2 微调最小代码Trainer、学习率与 epoch 怎么设下面的脚本用 HuggingFace 的 Trainer 训练一个复合标签的方面级情感分析模型。data_collator 会自动处理 padding并把 label 里的-100忽略掉避免[CLS]、[SEP]参与 loss 计算省去手写 mask 的麻烦。# train_absa.py import json from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForTokenClassification, TrainingArguments, Trainer, DataCollatorForTokenClassification ) model_name bert-base-chinese label_list [O, B-POS, I-POS, E-POS, S-POS, B-NEG, I-NEG, E-NEG, S-NEG, B-NEU, I-NEU, E-NEU, S-NEU] label2id {v: i for i, v in enumerate(label_list)} def encode_sample(item): tokens, labels item[tokens], item[labels] enc tokenizer(tokens, is_split_into_wordsTrue, truncationTrue, max_length128) word_ids enc.word_ids() aligned_labels [] prev None for wid in word_ids: if wid is None: aligned_labels.append(-100) continue lab labels[wid] if wid prev: # 子词延续把B/S/E统一改成I维持BIOES闭合 if lab.startswith(B-) or lab.startswith(S-) or lab.startswith(E-): polarity lab.split(-, 1)[1] lab I- polarity aligned_labels.append(label2id[lab]) prev wid enc[labels] aligned_labels return enc raw_data json.load(open(corpus.json, encodingutf-8)) tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForTokenClassification.from_pretrained( model_name, num_labelslen(label_list) ) dataset Dataset.from_list([encode_sample(x) for x in raw_data]) dataset dataset.train_test_split(test_size0.1, seed42) training_args TrainingArguments( output_dir./absa_model, learning_rate2e-5, per_device_train_batch_size8, per_device_eval_batch_size8, eval_strategyepoch, save_strategyepoch, num_train_epochs5, logging_dir./logs, seed42, ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset[train], eval_datasetdataset[test], tokenizertokenizer, data_collatorDataCollatorForTokenClassification(tokenizer), ) trainer.train()参数说明learning_rate2e-5是 BERT 微调的经验值比这大容易震荡比这小收敛太慢batch_size8是 CPU 也能跑的小批量如果你有 16G 以上显存可以调到 32但小数据上 batch 太大反而容易过拟合num_train_epochs5是我在中型语料上的折中数据只有几百条时 3 轮就够超过 10 轮几乎必然过拟合。如果使用老版本 transformerseval_strategy需要写成evaluation_strategy升级版本后旧写法会告警但不报错。3.3 保存、加载与推理从 pytorch_model.bin 到 span 输出训练完成后Trainer 会把 checkpoint 保存在 output_dir 下其中包含pytorch_model.bin、config.json和 tokenizer 文件。保存与加载建议用官方 API不要手动复制权重文件# 保存 model.save_pretrained(./absa_model) tokenizer.save_pretrained(./absa_model) # 加载 loaded_model AutoModelForTokenClassification.from_pretrained(./absa_model) loaded_tokenizer AutoTokenizer.from_pretrained(./absa_model)推理时需要把模型输出的 label id 转回标签再按 BIOES 规则把连续的 token 拼成方面词 span。下面这个函数可以直接用于单条评论import torch def predict(text, model, tokenizer, id2label): enc tokenizer(text, return_tensorspt, truncationTrue, max_length128) with torch.no_grad(): logits model(**enc).logits pred_ids logits.argmax(-1)[0].tolist() tokens tokenizer.convert_ids_to_tokens(enc[input_ids][0].tolist()) spans [] cur_span None for tok, label_id in zip(tokens, pred_ids): if tok in ([CLS], [SEP], [PAD]): continue lab id2label[label_id] if lab.startswith(B-): cur_span {text: tok.replace(##, ), polarity: lab.split(-, 1)[1]} elif lab.startswith(I-) and cur_span is not None: cur_span[text] tok.replace(##, ) elif lab.startswith(E-) and cur_span is not None: cur_span[text] tok.replace(##, ) spans.append(cur_span) cur_span None elif lab.startswith(S-): spans.append({text: tok.replace(##, ), polarity: lab.split(-, 1)[1]}) elif lab O: cur_span None return spans逻辑说明B-开启一个 spanI-和E-负责拼接中间字和收尾S-表示单字方面词O则清空当前 span。参数说明id2label可以从model.config.id2label拿到但要注意如果你重新定义了label_list必须确保模型保存时的 id 顺序和它一致否则推理结果会错位。zip 里如果已经带了训练好的模型直接替换./absa_model路径即可不需要重新训练。4. 用对的指标评估模型macro F1、混淆矩阵与 bad case 分析模型跑通了第一件事不是看 loss而是看评估指标。方面级别情感分析有一个很坑的地方大部分 token 是O模型就算把所有词都预测成非方面词token 级准确率也能到 80% 以上但这没有任何意义。4.1 整体准确率为什么失真在序列标注任务里标签分布极度不均衡O占绝大多数所以“整体准确率”是个黑匣子指标。两个模型一个真的学到了方面词一个只会输出全O准确率可能只差几个点。业界接受的做法是按 span 计算预测出来的方面词必须边界也对、极性也对才算一个正确预测。这个标准很严格指标涨起来慢但它反映的是真实可用性。4.2 精确率、召回率、F1 与宏平均按 span 计算时精确率是“模型预测出的方面词中有多少是对的”召回率是“真实方面词中有多少被找出来了”F1 是两者的调和平均。对于旅游场景类别不平衡很常见“停车”“无障碍设施”这类长尾方面可能只有十几条如果不看单独的召回率你根本发现不了它已经崩了。宏平均macro F1是先把每个极性类别各自的 F1 算出来再求平均。它不会因为某个大类样本多就掩盖小类的问题所以评估 ABSA 模型时我一般只看 macro F1 和每个极性的召回率而不是加权平均。4.3 评估脚本输出按情感类别的 P/R/F1 和混淆矩阵下面这段代码用简化的 text 匹配计算每个极性的 P/R/F1。严格版本应该用 start/end 偏移做 key但先用 text 能让你快速跑起来趋势判断不会错。def evaluate_span_list(gold_per_sample, pred_per_sample, polarities(POS, NEG, NEU)): stats {p: {tp: 0, fp: 0, fn: 0} for p in polarities} for golds, preds in zip(gold_per_sample, pred_per_sample): gold_map {} for g in golds: gold_map.setdefault(g[text], g[polarity]) pred_map {} for p in preds: pred_map.setdefault(p[text], p[polarity]) for pol in polarities: g_set {t for t, p in gold_map.items() if p pol} p_set {t for t, p in pred_map.items() if p pol} stats[pol][tp] len(g_set p_set) stats[pol][fp] len(p_set - g_set) stats[pol][fn] len(g_set - p_set) macro_f1 0.0 for pol in polarities: tp stats[pol][tp] fp stats[pol][fp] fn stats[pol][fn] prec tp / (tp fp) if tp fp else 0.0 rec tp / (tp fn) if tp fn else 0.0 f1 2 * prec * rec / (prec rec) if prec rec else 0.0 macro_f1 f1 print(f{pol}: P{prec:.3f} R{rec:.3f} F1{f1:.3f}) print(fmacro F1: {macro_f1 / len(polarities):.3f})参数说明gold_per_sample和pred_per_sample是长度相同的两个列表每个元素是该样本的 span 列表。要把多分类的混淆矩阵引进来可以继续扩展成按极性统计tp/fp/fn再填矩阵但日常迭代先看 P/R/F1 就够定位问题。我建议把预测结果和真实结果都导出成 JSON再跑这个脚本这样不管换了模型还是换了后处理规则都能重复对比。4.4 从 bad case 反推数据问题指标只是结果真正决定项目能不能上线的是 bad case 分析。把预测错得最多的评论打印出来你会发现大部分错误不是模型能力问题而是语料问题某个方面词没标、标签极性标反了、或者一个 span 的边界本身就含混。NEG 经常被预测成 POS大概率是训练语料里否定句太少NEU 几乎不出现就要考虑这个类别是否值得保留很多景区点评里中性表达占比很低硬凑第三类只会让模型更糊涂。先修语料再换模型这是 AB SA 项目里最容易被忽略的省钱路径。5. 标注不一致、预分词错位、旧 torch 权重五个必踩的坑与排查方法这个项目方向里有很多坑跟模型结构没关系纯粹是数据和生产环境的问题。下面五条几乎每次换数据、换机器都会遇到按“现象、原因、解决”的顺序说透。5.1 span 边界抖动loss 在降但 F1 不上涨现象训练时 loss 从 2 降到 0.1验证集 F1 却一直在 0.2 上下晃打印预测结果发现方面词总差一个字比如“人太多”预测成“人太”。原因语料里 span 边界标注不统一。有人把“人太多”整体标成方面词有人只标“人”还有人把“人太多”里的“多”漏掉模型学到的是混乱边界。解决训练前跑一次数据体检脚本把所有含B-和E-的样本按 span 文本聚合看同一表达是否有多种边界。定一条死规则标注统一到“名词短语”不把形容词观点词包进方面词如果实在统一不了就把“人太多”这类整体短语在预处理阶段归一化成一个固定 token。规则定得越早后面调模型的效率越高。5.2 先分词再进 BERT标签错位的经典翻车现象用 jieba 分词后把词列表喂给 tokenizer训练出来的模型预测结果全是乱的span 偏移严重。原因BERT 有自己的一套切词方式中文基本按字切词典里有的词也可能整词保留。你先用 jieba 切好等于强行给 BERT 套了一个外部词边界word_ids()的映射就对不上了。解决不要在预处理阶段做任何分词直接把句子或者原始 token 列表传给tokenizer(..., is_split_into_wordsTrue)用word_ids()做标签对齐。如果 zip 里的旧代码是“先 jieba 再送模型”的写法优先重写预处理而不是去调模型。5.3 老模型权重加载失败torch 版本、map_location 与 unexpected key现象从 zip 里解压出来的pytorch_model.bin在新环境里model.load_state_dict报错或者直接torch.load就抛异常。原因这个权重很可能是用老版本 PyTorch 保存的新版默认weights_onlyTrue会拒绝部分反序列化内容还有一种常见情况是模型在 GPU 上训练你在没有 GPU 的机器上直接加载键名对不上或设备不符。解决先确认本机 PyTorch 和 transformers 版本与项目 requirements 一致加载时加上map_locationcpu。如果确定报错来自新版的weights_only限制只对你信任的自训练模型使用weights_onlyFalseimport torch checkpoint torch.load(pytorch_model.bin, map_locationcpu, weights_onlyFalse)逻辑说明weights_onlyFalse会允许加载任意 Python 对象有潜在安全风险所以只适用于自己训练或来源可信的模型文件。参数说明如果加载后报unexpected key先对比config.json里的num_labels和当前模型的标签数量是否一致不一致时用model.load_state_dict(checkpoint, strictFalse)跳过缺失层但这种情况基本说明模型结构和标签集不匹配不能强行上线。5.4 长尾 aspect 召回为零停车、无障碍这类标签现象总体 F1 看起来还行但一查明细“停车”“无障碍设施”的召回率是 0模型一个都没预测出来。原因这些长尾方面在语料里只有十几条甚至几条模型学不到足够特征而且训练时它们对 loss 的贡献被大类淹没。解决不要只靠重采样那会让模型在小样本上过拟合。有效做法是去 OTA 点评里专门检索“停车”“轮椅”“无障碍”相关句子补充几百条再做标注或者在损失函数里给这些标签加类权重把 loss 贡献拉起来。评估时必须看 macro F1否则这类问题会被总体指标彻底掩盖。5.5 转折词与否定词的 scope模型老是把话里有话的评论判错现象输入“不推荐在旺季去人挤人”模型把“不推荐”判成正向或者把“旺季”判成负向但错过了“整体体验差”这个核心判断。原因否定词和转折词的作用范围scope很广BERT 虽然有上下文建模能力但在小语料上对这种语义作用学习不足而且很多语料只标了方面词没把否定词和观点的结构关系标出来。解决训练时在句子层面把“不推荐”这类组合词作为一个整体观点词标出帮助模型建立否定关系推理时再加一道后处理规则做修正这部分我放在第 6 章展开。不要指望模型自动学会所有否定逻辑尤其是数据量不大的时候。6. 进阶最后一道后处理把否定词和转折词修正确模型输出不是终点尤其是面对真实景区点评后处理规则经常能把 F1 再拉高一点。6.1 一个简单但好用的规则修正函数下面的函数在模型预测结果上做两层修正第一层方面词前面三个字内出现否定词且预测为 POS则取反为 NEG第二层如果句子中有“但”“但是”这类转折词强制把转折之后出现的情感作为该方面的主要判断依据。NEG_WORDS [不, 没, 别, 不太, 不推荐, 不要, 千万别] TURN_WORDS [但, 但是, 不过, 然而] def post_fix(text, spans): fixed [] for span in spans: cur_text span[text] polarity span[polarity] pos text.find(cur_text) before text[max(0, pos - 3):pos] for w in NEG_WORDS: if w in before and polarity POS: polarity NEG break for tw in TURN_WORDS: tw_pos text.find(tw) if tw_pos 0 and pos tw_pos: # 转折词之后出现的方面往往才是真实结论 pass fixed.append({text: cur_text, polarity: polarity}) return fixed逻辑说明这里before取的是方面词前三个字符能覆盖“不推荐”“不太”这类双字词也不会因为句子太长而误伤。转折词部分没有直接改极性它真正该做的是让你去检查“转折前的情感是铺垫、转折后才是结论”这个常见模式规则里的pass是提示你依据自己的语料决定要不要把转折后的文本单独抽出来再过一次分类器。参数说明否定词表必须来自你的语料统计照搬网上模板大概率会误伤例如“不太拥挤”里面的“不太”其实表达的是正面这种语义只能靠更长的 N-gram 规则别指望三行代码解决所有问题。6.2 用 20 条人工标注做验证每一次改动先跑 F1加规则最大的风险是过拟合到少数 bad case。我的习惯是准备 20 条独立的人工标注样本任何规则改动后先在固定测试集上跑一遍evaluate_span_list对比改动前后的 macro F1。如果提升小于 0.5 个点这个规则就不值得上线因为它可能只是恰好修对了几个样本换个数据集就翻车。这套验证方法比肉眼看好几个预测结果可靠得多。做这类项目我吃过不少亏最后养成的习惯就是先把训练数据体检一遍再定评估脚本最后才调模型。规则和参数都是小事数据和指标口径才是决定项目生死的大事。希望帮到你。本文还有配套的精品资源点击获取
返回列表