资讯动态

中文多标签情绪分类实战:模型设计、阈值调优与踩坑指南

发布时间:2026/10/17 8:48:49 来源:尧图企业网站定制
1. 从一个实际需求说起为什么情绪分类要打多标签最近做了一次小规模文本数据挖掘任务正好落在中文语句的情绪多标签分类上。一开始我以为这只是情绪分析的进阶版真上手以后发现连数据标注的逻辑都得从头捋。一个中文句子可以同时包含好几种情绪比如“终于抢到了但钱包空了”既有兴奋也有心疼“笑死这家店我等了一个月才发货”既有调侃也有不满。如果只用单标签这些复合信号就被强行压成某一个类模型无论怎么调都会在真实场景里翻车。这个项目最终做成了多标签给定一段中文文本预测它包含哪些情绪允许一个都不命中也允许同时命中多个。这个能力可以直接用到舆情监控、电商评论分析、客服工单自动打标等场景也适合想从单标签分类过渡到多标签分类的同学拿来做参考。1.1 单标签情绪分类看着简单实际不够用情绪分类最朴素的做法是情感极性判别把句子分成正向、负向、中性。再细一点会分喜悦、悲伤、愤怒、恐惧、惊讶、厌恶这些细粒度情绪。问题是真实文本很少是单一情绪的载体。用户写评论的时候往往带着混合情绪比如“活动力度大是好事但规则也太绕了”这句话既有正向认可又有负面烦躁。我一开始按“主要情绪”标数据让两位同学独立标同一批句子结果复合情绪句子的标注一致率只有三成左右。这不是标注员不认真而是单标签这个形式本身有问题。两个人都能看出这句子并存两种情绪但被规则逼着只能选一个标准自然飘忽。对下游业务来说这种信息压缩也无法接受。运营看到一条“等了好久终于发货了但快递也太慢了”的反馈如果模型只输出一个负面标签前面那份期待就被抹掉了。多标签允许这些情绪同时保留业务侧拿到的信息密度明显更高。1.2 多标签分类到底是什么和单标签有哪些本质区别多标签分类的输出不再是“从K个类别中选一个”而是“对K个候选情绪分别做一次是否判断”。模型输出一个K维向量每一维独立决定该情绪是否存在维度之间可以有相关但不做竞争。简单类比单标签像单选题多标签像体检勾选症状清单头痛和发热可以同时勾上。落到模型结构上差异也非常明确。单标签分类最后一层通常是K个节点接softmax把所有类别概率归一化到总和1用交叉熵作为损失。多标签分类最后一层同样可以放K个节点但激活函数换成sigmoid每个节点独立输出0到1的概率各维度互不约束损失函数也改成BCEWithLogitsLoss逐标签计算二分类交叉熵再取平均。不要小看这个改动它直接决定了模型会不会把“喜悦”的概率压低来迁就“惊讶”。多标签的标签空间结构、训练方式、评估指标以及阈值策略和单标签都是两套逻辑这也是我觉得这个项目值得单独记一笔的原因。2. 搭数据情绪标签体系、标注规范和预处理做多标签分类最先要承认的一点是数据层面的工作量远比模型大。我这次项目里训练数据和标注规则的打磨占了大概一半时间。很多人上来就直接跑模型结果损失函数来回震荡、指标很怪回头查原因十有八九是标签体系没立住或者标注标准不统一。2.1 情绪标签体系怎么定才不飘第一版我做了一个自以为很全的体系设了八类喜悦、悲伤、愤怒、恐惧、惊讶、厌恶、平静、焦虑外加一个“其他”。结果验证集表现很一般人工抽检发现“平静”和“其他”是重灾区。标注员看到一句“今天天气不错”会犹豫到底算“平静”还是“其他”这种边界模糊直接污染标签。后来我把体系收敛成六类喜悦、悲伤、愤怒、恐惧、惊讶、厌恶。这样做的逻辑是每个标签要有清晰的心理特征和语言表现形式不能只是“没有明显情绪”的兜底。没有情绪的情况就让整条样本所有标签都为0交给模型去学“空集”。对于“惊讶”我犹豫过因为它经常和正面或负面情绪叠加出现比如“惊喜”“惊吓”但多标签形式恰好能处理这种叠加所以保留。标签体系设计时还要注意颗粒度一致。不要一边用“愤怒”一边用“失望”这类边界相近的情绪否则标注员会频繁纠结模型学到的特征也会互相干扰。如果业务确实需要更细的“失望”“焦虑”那就在标准里写清楚它们和“悲伤”“恐惧”的边界判据。我在这个项目里选择六类就是想在覆盖度和一致性之间找平衡而不是追求教科书的完整清单。2.2 标注规范与质量抽检数据来自评论、微博和客服会话文本我按批次组织标注。每条样本给标注员展示一句话旁边列出六个情绪要求逐一判断是否存在并且明确允许不选、允许多选。最关键的一条规则写在最前面不要只挑最明显的那一个。这句话听着简单实际上大部分单标签标注习惯都会把人带偏必须反复强调。为了减少主观差异我补充了具体的判据示例。比如“愤怒”要求文本中有明确的不满倾向、指责语气或负面情绪词“恐惧”则要求有担忧、害怕、慌张等信号如果某个情绪在句中只是隐约存在占比不到一成就不标。这样的规则听起来有点机械但恰恰能提高一致性。质量控制方面我每批随机抽了约15%进行双人标注按单标签分别计算一致率而不是只算一个笼统的“整体一致”。多标签场景里整体一致参考意义不大按标签分开算更直观。比如“喜悦”一致率能到0.9“恐惧”却只有0.6说明后者的标注规则需要再补充案例。如果某些样本两个标注员产生了冲突我会让第三个人仲裁出现频率高的争议类型会写进新一版标注指南。这比盲目扩大标注量更有效几个批次下来争议率明显下降。2.3 文本预处理不是只有“分词”这一件事中文情感文本的预处理和通用NLP任务有点差别。首先要做的是基础清洗统一全角半角、简繁转换、去URL、去用户名、压缩连续标点。这些操作看起来基础但如果不做同样的情绪表达会因为格式差异被拆成不同特征尤其是对传统机器学习的特征抽取影响很大。然后要区分建模路线来决定要不要分词。如果走传统路线用TF-IDF加多标签分类器分词就非常关键。我用的是中文分词工具同时保留了bigram和trigram特征。理由很简单很多情绪表达是短语级的比如“太离谱了”“有点小感动”“真让人头大”单个词很难承载完整情绪。如果走预训练模型路线通常不需要显式分词模型内部会通过子词方式处理中文输入你只需要把整句话交给tokenizer。这里有个容易踩的坑不要对输入先做“只保留词表内词语”的过滤那会把本来有用的字符丢掉导致OOV问题被放大。数据格式上我统一整理成CSV核心列是text然后每列对应一个情绪标签值为0或1。多标签数据一般不会像多分类数据那样只给一列整数标签而是用0/1矩阵这个格式在后续训练、评估和错误分析里都会省事很多。3. 建模路线先跑通基线再上预训练模型这个项目里我没有一上来就上大模型而是先跑了一个传统机器学习基线。原因很朴素先知道自己手上数据的下限在哪后面换成深度模型才知道提升到底来自哪里。一套流水线跑通以后再切换到预训练模型两个结果放到一起对比比凭空调参靠谱得多。3.1 传统路线TF-IDF加多个二分类器多标签分类最简单的落地方法叫Binary Relevance思路是把K个标签拆成K个独立的二分类任务每个标签训练自己的分类器。这个方案最大的好处是简单可靠代码只需要一个循环。坏处是完全没有建模标签之间的依赖关系但在我这个数据量并不大的项目里它作为基线的效果足够说明问题。我用TF-IDF做文本向量化分类器选择逻辑回归。选逻辑回归而不是SVM或随机森林主要原因是它在中小规模文本上稳定、收敛快并且predict_proba的概率输出质量不错后续做阈值搜索很关键。参数上我设置了max_features取一万ngram_range用(1,2)sublinear_tf打开避免文本长度差异对词频造成过大扰动。每个标签单独配class_weightbalanced用少数类和多数类倒数的比例调权。代码大概长这样from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline label_cols [joy, sad, angry, fear, surprise, disgust] prob_dict {} for col in label_cols: pipe make_pipeline( TfidfVectorizer(max_features10000, ngram_range(1, 2), sublinear_tfTrue), LogisticRegression(class_weightbalanced, max_iter1000) ) pipe.fit(train_df[text], train_df[col]) prob_dict[col] pipe.predict_proba(valid_df[text])[:, 1]这里我没有把每个分类器的预测结果直接二值化而是保存了概率。原因后面会再提多标签任务里“概率转标签的阈值”是需要单独调的提前把概率存下来后面搜索阈值就方便了。3.2 预训练模型路线BERT类模型改造成多标签输出传统基线跑通后我切到了预训练模型方案。中文预训练模型对短文本的语义理解比TF-IDF强不少尤其在反讽、上下文依赖这类问题上差距明显。多标签改造不复杂本质就是把分类头换成一个Linear层输出维度等于标签数然后接BCE损失。这里要多说一句最后一层不要用softmax。softmax会让所有标签的概率互相竞争某类概率高了其他类就会被压低完全不符合多标签的假设。正确的做法是每个神经元独立接sigmoid让每个情绪概率只和它自身的特征有关。模型前向和损失计算的代码框架大致如下import torch.nn as nn from torch.nn import BCEWithLogitsLoss class MultiLabelClassifier(nn.Module): def __init__(self, base_model, num_labels): super().__init__() self.base_model base_model self.classifier nn.Linear(base_model.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask): outputs self.base_model(input_idsinput_ids, attention_maskattention_mask) return self.classifier(outputs.last_hidden_state[:, 0, :]) loss_fct BCEWithLogitsLoss() logits model(batch[input_ids], batch[attention_mask]) loss loss_fct(logits.view(-1, num_labels), batch[labels].float())有一点必须注意labels要转成float而不是int。BCEWithLogitsLoss内部对目标值按照浮点数做二分类交叉熵如果你的标签是整数类型不匹配很容易在训练时报错。我自己第一次跑就踩了这个坑后来把标签列全部转成float之后一切正常。训练时我用了较小的学习率大概在2e-5到3e-5区间批次大小设定为16或32。文本长度统一截断到128少数超长文本单独处理。这个短文本设置适合评论和客服会话如果业务里长文本占比高不能直接这么截断后文会专门说。3.3 从概率到标签阈值不该默认0.5模型输出的sigmoid概率如果直接用0.5二值化在多标签任务里通常不是最优解。原因并不难理解BCE损失优化的是概率接近真实值而不是自动把0.5作为最优判定边界当类别不平衡时某个标签训练出来的概率分布可能整体偏低0.5这个阈值会漏掉大量正例。反过来说如果某个标签的负例特别多0.5会导致大量误报。正确做法是拿验证集单独搜阈值。我的办法是每个标签在0.1到0.9之间按0.05步长滑动挑F1最高的那个阈值作为该标签的判定阈值。这样等于在模型训练完以后又做了一次轻量的后优化。代码大致长这样from sklearn.metrics import f1_score best_thresholds {} for col in label_cols: best_score 0 best_thr 0.5 for thr in [round(x / 20, 2) for x in range(2, 19)]: pred (prob_dict[col] thr).astype(int) score f1_score(valid_df[col], pred) if score best_score: best_score score best_thr thr best_thresholds[col] best_thr要注意的是这个阈值搜索一定基于验证集而不是训练集。在训练集上找阈值会严重过拟合模型在验证集上的表现会明显低于训练时看到的结果。另外如果项目上线后数据分布发生了变化阈值也应该跟着重新评估不要一劳永逸。4. 多了标签怎么评估指标选择与调参判断模型跑通后紧跟着的问题就是“到底怎么评价好不好”。我见过不少项目在这栽跟头拿了多标签模型的预测结果直接计算“准确率”发现数字低得没法看然后开始盲目调模型结构最后才知道罪魁祸首是指标选错。4.1 多标签准确率的坑单标签分类里“准确率”是理所当然的指标预测类别和真实类别完全一致就算对。但多标签里如果仍然这么算每一维都必须完全正确哪怕标签数量是6这个条件也会迅速变得极其苛刻。假设每个标签的准确率都是0.9六个标签同时全对的概率期望只有0.9的6次方也就是大约0.53。标签越多这个数字越难看哪怕模型本身已经表现不错。所以在多标签场景里正确率只能当参考不能当主指标。真正需要的是Hamming Loss、Micro-F1、Macro-F1和每个标签各自的精确率与召回率。指标没有绝对的谁优谁劣只看是否符合业务诉求。4.2 常用指标与适用场景指标通俗解释适用场景Hamming Loss预测结果与真实标签在所有标签位上不一致的占比越小越好快速评估整体打标稳定程度Exact Match / 子集准确率所有标签和真实标签完全一致才算对要求每个标签都要严格正确的场景Micro-F1把所有标签的预测汇总到一个全局混淆矩阵里算F1高频标签占主导、关心整体资源效率Macro-F1每个标签单独算F1再平均所有标签权重相同低频情绪也很重要的多标签任务说直白一点Micro-F1像是把所有样本、所有标签混在一起算一轮总KPI高频标签贡献大结果会被“喜悦”这类数量多的标签主导。Macro-F1则更像是给每个情绪类别单独考核再把各小组得分做平均“恐惧”这类样本再少也拥有和“喜悦”一样的投票权重。项目里我习惯两个都看再单独看每个标签的P/R这样才能知道是哪些类别拖了后腿。4.3 实际项目中该盯哪个指标指标选择要跟着业务价值走。以舆情监控为例一条被漏掉的“愤怒”可能带来公关风险这时候不能只盯整体F1要看“愤怒”这个标签的召回率确保绝大多数负面情绪被抓出来。反过来做客服满意度分析时如果系统动不动就把正常反馈误判成负面客服团队会被无效工单淹没这时候要看“愤怒”“厌恶”这类标签的精确率。如果只让我选一个综合指标我会看Macro-F1加每个标签的召回率。因为多标签任务里高频标签通常是容易学好的而真正体现模型价值的是那些低频标签有没有被保住。低频标签被阈值压得太狠Micro-F1可能看起来还行但业务一旦需要分析“恐惧”相关预警模型就形同虚设。5. 实操中踩过的坑与排查经验这一部分是我觉得整个项目最值钱的地方。很多问题都不是从论文或教程里能直接看到的而是在反复实验、上线验证、以及和标注同学来回拉扯中慢慢暴露出来的。如果只看准确率你可能永远发现不了问题藏在哪里。5.1 标签不平衡恐惧、愤怒这类情绪容易被学没情绪多标签数据天然存在明显的长尾。喜悦、悲伤样本数量可能比较充足恐惧、愤怒、厌恶往往少很多。这会导致BCE损失在优化时被多数类主导模型输出概率整体偏低后面对这些标签的阈值搜索也会很费劲容易出现“要么全判0要么疯狂误报”的两极状态。我有两个比较有效的处理办法。第一种是在损失函数里给少数类加正样本权重让模型更重视那些稀缺的正例。BCEWithLogitsLoss自带pos_weight参数传一个与标签数等长的权重向量比如某个标签的负样本数是正样本的6倍就把它的权重设为6左右。第二种是做采样调整对数量极少的标签进行过采样但对预训练模型来说过采样容易导致重复样本过密所以我更偏好使用pos_weight。需要注意权重不能给得过于极端否则模型会把噪声也一起放大指标反而变差。5.2 标注噪声和情绪歧义怎么兜底情绪标注的主观性很难完全消灭。同一个句子一个人觉得是“愤怒”另一个人可能只读出“惊讶”。更麻烦的是反讽比如“修了三次还修不好你们可真是厉害”字面像夸奖实际是吐槽。人工标注员尚且有分歧模型学到的东西自然也不干净。我做了一个折中方案把双人标注中冲突率高的样本单独拿出来先不直接送进训练集而是让第三人仲裁。仲裁后如果仍然有严重分歧我会给这些样本降低训练权重或者在评估时把它们排除在硬指标之外。这样做的效果比强行统一标注结果更好至少模型不会因为一条矛盾样本在梯度上反复拉扯。另一个经验是不要总想靠换更大模型去解决标注噪声。我在这个项目里对比过基础预训练模型和一个更大规模的模型清洗数据后的提升远比换大模型明显。数据质量如果不过关更大的模型只是在更努力地记住噪声。5.3 长文本与推理过程中的几个工程细节文本长度对预训练模型的影响非常直接。中文评论和客服会话经常超过512个token而预训练模型的位置编码通常只支持到512。不少人直接截断前512个字符这个做法风险很大因为情绪常常出现在文末。比如一段长客服记录开头是客观描述最后一句才是“客服半天不理人差评”。如果只保留开头模型完全看不到情绪信号。我采取的方案是首尾拼接保留开头一部分上下文再保留结尾一部分中间用分隔符连接。如果文本过长则用滑窗切分成多个片段分别预测最后对各片段的情绪概率做平均或取最大值再和阈值做比较。这个做法比简单截断带来的提升明显代价是推理时间变长。推理阶段还有两个细节容易忽略。一是尽量做batch推理不要在一个for循环里逐条调模型接口否则GPU利用率会很低。二是多标签模型的阈值要跟着模型版本一起保存和版本化管理不能只存权重文件。项目后期我发现如果阈值丢失新模型的表现会看起来和旧模型完全不可比排查了半天才发现是判定阈值被人为改回0.5了。5.4 最后一点个人体会这个项目做到后面我最深的感受是多标签情绪分类的难点不在“多标签”这三个字而在怎么定义标签、怎么标数据、怎么解读输出。模型结构上的改动其实很小从头到尾可能就是换一个激活函数、换一个损失函数的事真正决定成败的是标签体系是否稳定、标注噪声是否可控、阈值是否符合业务需要。如果你也正在做类似任务我的建议是先把你的验证集做厚一点把每个标签的单独表现盯住不要被整体F1迷惑。调阈值之前先问自己一句那一类漏报对业务更致命还是误报更致命想清楚这个你自然知道该往哪个方向调。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑