拓冰建站拓冰建站
首页 / 资讯中心 / 正文

对话情感分类:三句话上下文窗口与BERT微调实战

简介这是一份面向大三人工智能/NLP课程的对话情感分类完整实践资源聚焦A与B三轮对话中A的情感状态识别采用LSTM建模序列上下文并融入预训练模型思路与PyTorch实现适合初学自然语言处理、情感分析及序列建模的读者对照学习。压缩包共30个文件涵盖7个txt说明与配置、6个Python源码、4个xml工程文件、3个pyc缓存、2个zip数据包以及pdf/doc实验报告模板、pptx答辩讲稿和mp4操作录屏等包体约29.17MB。已有509人学习浏览。资源从数据预处理、模型搭建到训练推理均有代码与说明支撑并提供预训练模型缺失的解决提示、实验报告模板和会议录屏便于学习者快速复现项目、理解LSTM与情感分类任务的关键流程也可作为同类NLP作业的参考范式。1. 三句话不是拍脑袋对话情感分类的任务边界在客服对话质检里用户连续发来“好的。”“那就这样吧。”“你尽快。”如果只看最后一句话情感倾向几乎全是中性但把前后三句话连起来看明显已经是不满甚至投诉前的信号。这种“三句话”窗口就是自然语言处理里对话情感分类一个既轻量又有效的上下文粒度它不像整场对话那样受话题漂移干扰也不像单句分类那样丢失话轮间的转折、反讽和说话人关系。这里面向做智能客服、语音助手和对话系统文本分析的工程师讲清楚为什么三句话能立住、如何把原始对话切成三句话样本、用预训练模型训练一个可用的分类器以及线上部署时要处理哪些实际问题。2. 对话情感分类为什么要相邻三句话上下文窗口的取舍传统句级情感分析认定句子语义独立但对话里的“挺好的”“你看着办”往往要结合说话人上一轮或下一轮才能判断。对话情感分类需要先确定上下文窗口窗口太小模型看不到转折窗口太大样本稀疏、训练不稳定。我一般会先用相邻三句话起步原因是它在准确率和成本之间正好处在一个可解释的位置。2.1 一句话无法覆盖的反讽与转折只给一句话时分类器能用的信号只有表层词汇和句法。礼貌用语、反讽、省略主语都会把模型带偏。比如用户说“我谢谢你”单看这句话模型几乎必然判成正向但当上一句是“当初跟我说今天一定能到”下一句是“我谢谢你”整个三句话窗口表达的是明显不满。这就是对话情感分类和普通情感分类最本质的区别它要理解话轮之间的对比关系而这种关系通常落在相邻的两三次发言里。可靠性问题来了模型见到“谢谢”这个词在预训练语料里大概率关联正向微调若只给单句很难覆盖这类反讽给更多上下文后模型才有机会把“承诺—落空—嘲讽”这个事件链编码进注意力矩阵。从标注角度说人工标注员也需要看到前后文才能一致地标注单句会出现大量“无法判断”的样本。2.2 三句话作为基本的语用单元一句对话的完整反馈常常是“发起—响应—评价”。客服说“马上为您处理”用户说“好”然后用户补一句“大概多久”。这三句构成一个最小决策单元。如果窗口再短评价缺少前因如果窗口拉长到整场评价又会被无关话题干扰。三句话正好能覆盖一次“请求—确认—追问”或“承诺—等待—催促”的完整情绪变化。从工程上看三句话窗口使输入长度稳定。中文对话平均一句话约 10-20 个字三句话加上分隔符一般不超过 90 个 token用 128 的 max_length 就能覆盖绝大多数情况。模型不需要为长文档做分段或摘要推理延迟也容易控制。这个特性在做实时客服质检时非常重要因为每个在线会话都要逐句打分。为了说明窗口大小的影响可以把三类窗口放在一起比较窗口范围能捕捉的信息主要风险适用场景单句情感词、程度词反讽、指代、礼貌用语误判商品评论、短文本相邻三句话话轮间转折、催促、追问无法覆盖远距离事件客服质检、语音助手整场对话长期情绪累积和话题漂移标注成本高、注意力稀疏投诉归因、心理辅导这个表不是某个数据集的绝对结论而是工程选型时的常用判断。如果你所在业务里用户会在五句话之后才表达不满那三句话窗口显然不够需要在实验阶段用长对话数据做对比但绝大多数客服和助理场景三句话已经能带来相对单句非常明显的 F1 提升。2.3 长窗口与短窗口的定位差异基于上表整场对话模型常用 Attention 或 Memory 结构比如 BERT-Long 或者层次 Transformer。它们能建模情感如何累积但训练数据需要完整的对话级标签而大部分项目的标注粒度是话轮级。对话情感分类的三句话方案本质上是把“话轮级标签 局部上下文”绑定在一起让模型每次只负责预测一个目标句因此数据利用率更高。如果后续要支持更长的上下文常见的做法是双塔或多粒度模型第一层用三句话窗口做局部预测第二层对整场对话的局部预测序列做时序池化。先做好三句话模型再往上叠加长窗口迭代路径比一开始直接上整场模型要稳得多。这个演进路线在很多对话系统里都被验证过也是自然语言处理从单句任务迁移到对话任务时常用的一步。2.4 三句话样本的边界处理与一致性对话第一句和最后一句没有前后文处理方式是在拼接时直接省略缺失部分不填充任何占位符。常见错误是复制相邻句子填充到缺失位置这会让模型学到“重复”这个特征并影响真实首尾句判断。另一个更严格的做法是使用 attention_mask 屏蔽占位符但对三句话这样的小窗口来说省略法已经足够稳定关键是训练和推理必须保持一致。还有一点很多人会忽略如果训练时对边界样本做省略推理时也必须做同样的省略。比如训练阶段首句没有前文你只把目标句和后文拼进去线上调用时也必须按同一逻辑拼输入不能在首句前面加一个“无”否则模型看到的是分布外的输入预测置信度会整体偏移。提示在划分训练集和测试集时务必按dialog_id划分不要随机按行切分。同一段对话里的相邻三句话样本高度重叠按行切分会把上下文句子泄漏进验证集导致指标虚高。3. 把对话转成三句话训练样本清洗、对齐与标签原始对话一般以消息流形式存储字段包括会话 ID、发送时间、说话人、消息内容。不可能直接把整场对话丢给模型。正确做法是把每个话轮的目标句子取出来配上前后相邻话轮生成一条样本这条样本的标签就是目标句的情感。3.1 原始对话字段与角色转写先整理出结构化字段常见格式如下字段示例必填说明dialog_id10023是会话唯一 ID用于防泄漏turn_index5是话轮序号从 0 开始speakercustomer是customer / support / systemtext我这边收不到验证码是清洗后的消息文本sentiment_label2是0 负向、1 中性、2 正向清洗时我会先做三件事把换行符替换为空格把手机号、地址、银行卡号替换为掩码去掉纯系统通知消息或者把它单独标记为system。不做的后果是模型会在验证码这一类词上过拟合把“数字含有敏感信息”当成情感信号。注意不要使用任务相关的关键词过滤例如直接删除“投诉”两个字否则会破坏语义。3.2 三句话窗口的生成规则给定对话列表dialog对第idx个话轮生成样本时取idx-1、idx、idx1分别作为前文、目标句、后文。目标句是当前要分类的句子前后文只是辅助信号。下面这个函数输出一个字典def make_triplet(dialog, idx): prev_turn dialog[idx - 1] if idx 0 else None target_turn dialog[idx] nxt_turn dialog[idx 1] if idx len(dialog) - 1 else None return { prev: prev_turn[text] if prev_turn else None, target: target_turn[text], nxt: nxt_turn[text] if nxt_turn else None, prev_speaker: prev_turn[speaker] if prev_turn else None, target_speaker: target_turn[speaker], next_speaker: nxt_turn[speaker] if nxt_turn else None, label: target_turn[sentiment_label], dialog_id: target_turn[dialog_id], turn_index: target_turn[turn_index], }这个函数有两个关键点。第一缺失位置用None表示而不是空字符串这样后续拼接时可以判断有没有这一句。第二返回值里带有dialog_id和turn_index后续做数据划分和缓存都要用它们。对整段对话执行这个函数会得到与话轮数相同的样本数样本之间并不独立同一目标句的子句会出现在相邻样本里因此只能在dialog_id层面划分数据集。3.3 三句话的拼接策略与分隔符有了prev / target / nxt之后如何拼成一个字符串会影响模型效果。常见做法是直接连接中间用[SEP]分隔def join_triplet(sample): parts [] if sample.get(prev): parts.append(sample[prev]) parts.append(sample[target]) if sample.get(nxt): parts.append(sample[nxt]) return [SEP] .join(parts)这里把[SEP]作为纯文本传给 tokenizer由分词器转成 id。使用[SEP]做分隔符的主要原因是预训练模型在训练时见过这种分隔方式而不是像|||这样的自定义符号。对话里“谁说的”比“说了什么”更重要所以我会在每句话前加角色前缀def join_triplet_with_speaker(sample): parts [] if sample.get(prev): prev_role 客服 if sample[prev_speaker] support else 用户 parts.append(f{prev_role}{sample[prev]}) target_role 客服 if sample[target_speaker] support else 用户 parts.append(f{target_role}{sample[target]}) if sample.get(nxt): nxt_role 客服 if sample[next_speaker] support else 用户 parts.append(f{nxt_role}{sample[nxt]}) return [SEP] .join(parts)同一句“你慢点”客服说出来可能只是提醒用户说出来却是催促。角色前缀把这种差异显式带到输入层比让模型自己从对话顺序中推断更直接。实验时可以先跑不加角色的版本如果验证集 F1 提升不明显再考虑加多数场景下加了角色会更稳。3.4 类别标签与数据平衡三分类标签通常定义为0负向、1中性、2正向。标注时给标注员看到三句话窗口而不是只给目标句能明显减少分歧。数据不平衡时先不要急着做欠采样。更稳妥的做法是保留原始分布训练时用类别权重或阈值搜索后面第 5 章会给出方法。还要注意同一句话在不同上下文里情感不同。比如“好的”在新建工单时是中性的在用户催促三次后回答“好的”可能是负向。这要求标注规则必须定义为“目标句在三句话上下文里的情感”而不是“去掉上下文这句话的情感”。如果你的标注任务里没有把上下文展示给标注员即使模型结构换成更大的预训练模型数据质量也会成为天花板。4. 用 BERT 实现三句话情感分类最小可复现代码三句话分类本质上还是文本分类用自带上下文的文本序列微调一个预训练模型即可。中文场景我最常选bert-base-chinese如果数据来自特定领域比如法律或医疗优先换领域预训练模型。下面的代码基于transformers以训练一个三分类模型为例。4.1 加载预训练模型与分词器from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels3 )num_labels3对应负向、中性、正向三类。模型最后一层会新初始化一个 3 维的分类头因此必须跟任务数据做微调。直接用现成的权重做推理只能输出预训练时学到的通用表示不会遵守你的标签体系。4.2 构造输入并训练先定义一个将样本转成模型输入的函数沿用第 3 章的join_tripletfrom datasets import Dataset from transformers import Trainer, TrainingArguments from sklearn.metrics import f1_score, accuracy_score import numpy as np def tokenize_triplet(sample): text join_triplet(sample) encoded tokenizer( text, max_length128, truncationTrue, paddingmax_length ) encoded[labels] sample[label] return encoded train_dataset Dataset.from_list(train_samples).map(tokenize_triplet) eval_dataset Dataset.from_list(eval_samples).map(tokenize_triplet)max_length128覆盖三句话场景够用如果你的对话经常超过 40 个字/句可以调到 192 或 256。truncationTrue保证超长输入不会报错但要注意默认截断策略是从右边截掉这会把第三句话的一部分丢掉。对三句话分类来说更合理的做法是后续按线上数据的长度分布再调。训练参数和评估函数如下training_args TrainingArguments( output_dir./dialogue_sentiment, evaluation_strategyepoch, save_strategyepoch, learning_rate2e-5, per_device_train_batch_size16, per_device_eval_batch_size32, num_train_epochs3, weight_decay0.01, load_best_model_at_endTrue, metric_for_best_modelf1, ) def compute_metrics(eval_pred): logits, labels eval_pred preds np.argmax(logits, axis-1) return { accuracy: accuracy_score(labels, preds), f1: f1_score(labels, preds, averagemacro), } trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset, tokenizertokenizer, compute_metricscompute_metrics, ) trainer.train()这里给出训练侧常用超参数范围参数推荐值注意点learning_rate1e-5 ~ 5e-52e-5 在大多数对话文本上是稳妥起点batch_size16 / 32显存不够时优先减到 8配合梯度累积max_length128 / 192三句话总长度长对话场景再加大epochs3 ~ 5数据量小容易过拟合配合早停weight_decay0.01对分类头有明显正则效果用metric_for_best_modelf1而不是准确率是因为对话情感标签通常不平衡准确率会把多数类带偏。load_best_model_at_endTrue会在训练结束后回滚到验证集 F1 最高的一步。如果你的样本量只有几千条3 个 epoch 常常不够但增加 epoch 又会过拟合最优做法是把早停的patience设为 2再配合num_train_epochs5。4.3 推理阶段的滑动窗口与批处理训练完成后线上推理仍要逐句做窗口滑动。核心代码import torch def predict_dialogue(model, tokenizer, dialog): model.eval() predictions [] for i, turn in enumerate(dialog): prev dialog[i - 1] if i 0 else None nxt dialog[i 1] if i len(dialog) - 1 else None parts [] if prev: parts.append(prev[text]) parts.append(turn[text]) if nxt: parts.append(nxt[text]) text [SEP] .join(parts) inputs tokenizer(text, return_tensorspt, max_length128, truncationTrue) inputs {k: v.to(model.device) for k, v in inputs.items()} with torch.no_grad(): logits model(**inputs).logits probs torch.softmax(logits, dim-1).squeeze(0).cpu().numpy() predictions.append({label: int(np.argmax(probs)), probs: probs}) return predictions这里对边界样本的拼接方式必须和第 3 章训练样本保持一致不能用空字符串或占位符。推理时model.eval()和torch.no_grad()缺一不可否则 Dropout 和模型内部的状态会让同一个句子两次预测结果不一样。return_tensorspt后要把输入移到模型所在设备真正上线时不要逐句调用模型应该把整个dialog里所有三句话窗口都 tokenize 成一个 batch 再推理吞吐量会高很多。5. 三句话模型的调参与误判把 F1 提上去再上线模型能跑通只是第一步。对话情感分类的瓶颈通常在少数类召回和上下文误判。下面按我自己的调参顺序来讲先改阈值再改损失函数最后看误判样本。5.1 类别不均衡时的阈值选择如果负向样本只占 10%模型即使把负向全部判成中性准确率也可能有 80% 以上。这时候要先把默认argmax变成带置信度阈值的预测逻辑还是先取概率最大的类别但如果最大概率低于某个类别的置信度阈值就把它改判成中性。def apply_thresholds(probs, thresholds): preds np.argmax(probs, axis1) max_probs probs.max(axis1) # 对负向(0)和正向(2)单独设置阈值低于阈值时归到中性(1) for cls_id, threshold in enumerate(thresholds): if threshold 1.0: continue mask (preds cls_id) (max_probs threshold) preds[mask] 1 return predsthresholds是一个长度为 3 的数组例如[0.5, 1.0, 0.6]表示负向最少要有 0.5 置信度、正向最少要有 0.6中性始终采用原始预测。在验证集上搜索这个数组def search_thresholds(probs, labels, step0.05): best_score, best_thresh 0, [0.5, 1.0, 0.5] for t0 in np.arange(0.4, 0.8, step): for t2 in np.arange(0.4, 0.8, step): preds apply_thresholds(probs, [t0, 1.0, t2]) f1 f1_score(labels, preds, averagemacro) if f1 best_score: best_score, best_thresh f1, [t0, 1.0, t2] return best_thresh搜索时不要把三个阈值同时放在网格里类别 0 和 2 的阈值交互已经足够中性阈值固定成 1.0因为最终兜底就是中性。搜索完要在测试集上复算一次避免阈值在验证集上过拟合。5.2 标签平滑与损失函数调整阈值只改变决策边界不改变模型训练。如果负向样本太少应该让模型在训练时更关注负向。常见做法是用class_weight给CrossEntropyLoss加权。transformers的Trainer里可以通过重写compute_loss引入权重import torch.nn as nn class WeightedTrainer(Trainer): def __init__(self, class_weightsNone, *args, **kwargs): super().__init__(*args, **kwargs) self.class_weights class_weights def compute_loss(self, model, inputs, return_outputsFalse): labels inputs.pop(labels) outputs model(**inputs) logits outputs.logits loss_fct nn.CrossEntropyLoss(weightself.class_weights) loss loss_fct(logits, labels) return (loss, outputs) if return_outputs else lossclass_weights可以用 sklearn 计算from sklearn.utils.class_weight import compute_class_weight class_weights torch.tensor( compute_class_weight(balanced, classesnp.array([0, 1, 2]), ytrain_labels), dtypetorch.float32, ).to(model.device)注意compute_class_weight的输入是训练集所有标签不是某个 batch。权重值会放大少数类的梯度但也容易把多数类拉到别的类。经验上当少数类占比低于 5% 时我才会用否则先调阈值更稳定。标签平滑则是另一个选择可以在TrainingArguments里设label_smoothing_factor0.1让模型不再对训练标签过于自信。它不会直接提升负向召回但能减少模型在“我谢谢你”这类歧义样本上的过拟合风险。5.3 三句话里的典型误判病例无论阈值和 loss 怎么调最后都要回到样本上看。下面是三句话分类中最常见的一类误判目标句三句话上下文初版模型输出期望输出你慢点客服我马上查一下 / 用户好的 / 用户你慢点负向负向我谢谢你客服已经退款了 / 用户之前答应今天到 / 用户我谢谢你正向负向行吧客服需要等三天 / 用户那我应该等吗 / 用户行吧中性负向第一行模型能学会因为“你慢点”本身就是负面表达。真正难的是第二、三行目标句是完全的礼貌用语或弱反馈情绪藏在“之前答应”和“需要等三天”这两个前提里。如果模型把这类样本判错先不要怀疑模型结构应该检查训练集里是否缺少对应的上下文模式。可以按“目标句是礼貌词但整体负向”这个条件从日志里检索类似样本用几百条补充训练通常比换模型更有效。误判还有一个来源是说话人角色。客服的句子往往中性用户的句子负向更多模型容易把目标句的说话人角色当成捷径。如果测试集里客服和用户的话轮比例失调F1 会被“假高分”掩盖。在第 3 章的join_triplet_with_speaker中显式加入角色前缀能有效缓解这个问题如果你用的是token_type_ids也要保证角色和分段对应不要直接把所有句子都当成同一角色。6. 部署三句话分类器的缓存、降级与日志回流模型训练好后真正上线要考虑的不只是准确率。对话系统里一个误判可能触发错误动作比如把负向情绪误判成中性而漏掉投诉或者把中性误判成负向导致客服系统主动弹窗。三句话窗口本身很小适合在服务端做灵活处理。6.1 低置信度直接落规则我一般会在模型输出层后面加一道规则兜底。当probs.max() 0.6或者目标句文本命中“投诉、退款、诈骗、差评”等强负向词时不信任模型的低置信度输出直接给负向标签。这个兜底规则只处理极端情况不参与微调因此不会干扰模型学习上下文特征。另一种做法是在标注阶段给中性类添加更多数据但成本通常高于规则。6.2 结果缓存与批量打分同一个对话可能被下游多个系统请求比如实时在线质检和离线报表都会调用情感分类。以(dialog_id, turn_index, model_version)作为缓存 key把probs数组写入 RedisTTL 设置为 7 天。模型重训后必须更换model_version避免新旧模型混用导致标签不稳定。缓存命中后直接返回结果不再重复走 BERT。对离线批量打分不要逐句请求 HTTP而是把同一个对话的三句话窗口组成 batch用一次模型推理完成延迟和 GPU 利用率都会好很多。6.3 日志回流三句话样本线上的prev / target / nxt / 说话人 / 概率分布 / 人工复核标签都需要落日志。抽样策略是取模型高置信度但人工复核标签与模型不一致的样本加入下一轮回源训练同时保留置信度介于 0.4-0.6 之间的边界样本防止模型只在容易样本上进一步过拟合。日志字段里的prev / target / nxt必须与训练代码生成规则一致如果线上和训练在边界句拼接上不一致回流样本就等于脏数据。到这里三句话窗口的整个链路已经有了一个可以落地的闭环。下一轮模型升级时最先应该看的不是新模型结构而是回流样本里“上下文不一致导致标签翻转”的比例。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门