对话情感分类:三句话上下文窗口与BERT微调实战
2026/9/14 14:15:32 网站建设 项目流程

简介:这是一份面向大三人工智能/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 开始
speakercustomercustomer / support / system
text我这边收不到验证码清洗后的消息文本
sentiment_label20 负向、1 中性、2 正向

清洗时我会先做三件事:把换行符替换为空格,把手机号、地址、银行卡号替换为掩码,去掉纯系统通知消息或者把它单独标记为system。不做的后果是模型会在验证码这一类词上过拟合,把“数字含有敏感信息”当成情感信号。注意不要使用任务相关的关键词过滤,例如直接删除“投诉”两个字,否则会破坏语义。

3.2 三句话窗口的生成规则

给定对话列表dialog,对第idx个话轮生成样本时,取idx-1idxidx+1分别作为前文、目标句、后文。目标句是当前要分类的句子,前后文只是辅助信号。下面这个函数输出一个字典:

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_idturn_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_labels=3 )

num_labels=3对应负向、中性、正向三类。模型最后一层会新初始化一个 3 维的分类头,因此必须跟任务数据做微调。直接用现成的权重做推理只能输出预训练时学到的通用表示,不会遵守你的标签体系。

4.2 构造输入并训练

先定义一个将样本转成模型输入的函数,沿用第 3 章的join_triplet

from 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_length=128, truncation=True, padding="max_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_length=128覆盖三句话场景够用;如果你的对话经常超过 40 个字/句,可以调到 192 或 256。truncation=True保证超长输入不会报错,但要注意默认截断策略是从右边截掉,这会把第三句话的一部分丢掉。对三句话分类来说,更合理的做法是后续按线上数据的长度分布再调。

训练参数和评估函数如下:

training_args = TrainingArguments( output_dir="./dialogue_sentiment", evaluation_strategy="epoch", save_strategy="epoch", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=3, weight_decay=0.01, load_best_model_at_end=True, metric_for_best_model="f1", ) 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, average="macro"), } trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, tokenizer=tokenizer, compute_metrics=compute_metrics, ) trainer.train()

这里给出训练侧常用超参数范围:

参数推荐值注意点
learning_rate1e-5 ~ 5e-52e-5 在大多数对话文本上是稳妥起点
batch_size16 / 32显存不够时优先减到 8,配合梯度累积
max_length128 / 192三句话总长度,长对话场景再加大
epochs3 ~ 5数据量小容易过拟合,配合早停
weight_decay0.01对分类头有明显正则效果

metric_for_best_model="f1"而不是准确率,是因为对话情感标签通常不平衡,准确率会把多数类带偏。load_best_model_at_end=True会在训练结束后回滚到验证集 F1 最高的一步。如果你的样本量只有几千条,3 个 epoch 常常不够,但增加 epoch 又会过拟合;最优做法是把早停的patience设为 2,再配合num_train_epochs=5

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_tensors="pt", max_length=128, truncation=True) 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_tensors="pt"后要把输入移到模型所在设备;真正上线时不要逐句调用模型,应该把整个dialog里所有三句话窗口都 tokenize 成一个 batch 再推理,吞吐量会高很多。

5. 三句话模型的调参与误判:把 F1 提上去再上线

模型能跑通只是第一步。对话情感分类的瓶颈通常在少数类召回和上下文误判。下面按我自己的调参顺序来讲,先改阈值,再改损失函数,最后看误判样本。

5.1 类别不均衡时的阈值选择

如果负向样本只占 10%,模型即使把负向全部判成中性,准确率也可能有 80% 以上。这时候要先把默认argmax变成带置信度阈值的预测逻辑:还是先取概率最大的类别,但如果最大概率低于某个类别的置信度阈值,就把它改判成中性。

def apply_thresholds(probs, thresholds): preds = np.argmax(probs, axis=1) max_probs = probs.max(axis=1) # 对负向(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 preds

thresholds是一个长度为 3 的数组,例如[0.5, 1.0, 0.6],表示负向最少要有 0.5 置信度、正向最少要有 0.6,中性始终采用原始预测。在验证集上搜索这个数组:

def search_thresholds(probs, labels, step=0.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, average="macro") if f1 > best_score: best_score, best_thresh = f1, [t0, 1.0, t2] return best_thresh

搜索时不要把三个阈值同时放在网格里,类别 0 和 2 的阈值交互已经足够;中性阈值固定成 1.0,因为最终兜底就是中性。搜索完要在测试集上复算一次,避免阈值在验证集上过拟合。

5.2 标签平滑与损失函数调整

阈值只改变决策边界,不改变模型训练。如果负向样本太少,应该让模型在训练时更关注负向。常见做法是用class_weightCrossEntropyLoss加权。transformersTrainer里可以通过重写compute_loss引入权重:

import torch.nn as nn class WeightedTrainer(Trainer): def __init__(self, class_weights=None, *args, **kwargs): super().__init__(*args, **kwargs) self.class_weights = class_weights def compute_loss(self, model, inputs, return_outputs=False): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits loss_fct = nn.CrossEntropyLoss(weight=self.class_weights) loss = loss_fct(logits, labels) return (loss, outputs) if return_outputs else loss

class_weights可以用 sklearn 计算:

from sklearn.utils.class_weight import compute_class_weight class_weights = torch.tensor( compute_class_weight("balanced", classes=np.array([0, 1, 2]), y=train_labels), dtype=torch.float32, ).to(model.device)

注意,compute_class_weight的输入是训练集所有标签,不是某个 batch。权重值会放大少数类的梯度,但也容易把多数类拉到别的类。经验上,当少数类占比低于 5% 时我才会用,否则先调阈值更稳定。标签平滑则是另一个选择,可以在TrainingArguments里设label_smoothing_factor=0.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数组写入 Redis,TTL 设置为 7 天。模型重训后必须更换model_version,避免新旧模型混用导致标签不稳定。缓存命中后直接返回结果,不再重复走 BERT。对离线批量打分,不要逐句请求 HTTP,而是把同一个对话的三句话窗口组成 batch,用一次模型推理完成,延迟和 GPU 利用率都会好很多。

6.3 日志回流三句话样本

线上的prev / target / nxt / 说话人 / 概率分布 / 人工复核标签都需要落日志。抽样策略是:取模型高置信度但人工复核标签与模型不一致的样本,加入下一轮回源训练;同时保留置信度介于 0.4-0.6 之间的边界样本,防止模型只在容易样本上进一步过拟合。日志字段里的prev / target / nxt必须与训练代码生成规则一致,如果线上和训练在边界句拼接上不一致,回流样本就等于脏数据。

到这里,三句话窗口的整个链路已经有了一个可以落地的闭环。下一轮模型升级时,最先应该看的不是新模型结构,而是回流样本里“上下文不一致导致标签翻转”的比例。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询