资讯动态

多标签文本分类的评估与优化:用汉明损失替代准确率

发布时间:2026/10/22 5:07:29 来源:尧图企业网站定制
做多标签文本分类时最容易踩的坑就是把单标签那套“准确率”思维直接搬过来用。我接到过不少这类需求最典型的是工单自动打标一条工单可能同时命中“故障”和“支付”两个标签也可能一个都不该命中。你在这种场景下算准确率模型几乎永远“不准”因为要求所有标签都预测对才算对。真正能反映模型好坏的是汉明损失Hamming Loss它只按“单个标签预测出错的比例”来计差一个标签就记一个错差三个就记三个错这样既不会因为全对才得满分而让模型背负过重压力也不会把“错一半”和“全错”混为一谈。这篇文章我就拿一个短文本工单分类的实际案例把汉明距离和汉明损失从原理讲到代码再讲怎么用它反过来优化模型。全文代码基于Python和PyTorch完整可运行结尾附上我在真实项目里遇到的坑和排查经验。适合正在做多标签文本分类、或者被“准确率虚高/虚低”困扰的同学参考。1. 项目整体设计与思路拆解1.1 多标签分类的评估困境为什么准确率不顶用先明确一下场景。我做的这个模拟项目是“工单自动打标”每条文案可能属于多个类别比如“转账到一半提示网络异常钱扣了但对方没收到”这句里同时包含“支付”和“网络”两个主题。如果用传统的 Accuracy其实就是 Subset Accuracy评估模型只有把这两个标签同时预测对、其他18个标签又全部预测为0才算这名样本正确。在多标签场景里这种概率极低所以准确率通常会惨到个位数而且样本越复杂、类别越多数值越难看。反过来如果只看“准确率上升了”也说明不了问题。我有一次调参把准确率从 8% 提到 13%看着涨了不少实际是模型变“保守”了——它开始拼命输出负标签把大多数样本都判成“全零”这样反而会让很多真实有标签的样本被漏掉。多标签分类真正需要的是“标签级别”的量化工具。汉明损失的核心思路很简单逐标签比较预测和真实值错了几个标签就记几次最后除以总标签数。它不会因为一个样本里其他19个标签都对了、唯独1个错了就把这个样本一棍子打死也不会隐瞒“错了4个标签”这种严重偏差。1.2 汉明距离与汉明损失定义、直觉和数学表达汉明距离Hamming Distance原本是信息论里的概念用来衡量两个等长字符串之间有多少个位置的字符不同。在分类任务里我们把它用在二进制标签向量上预测标签向量和真实标签向量逐位比较不一样的位数就是汉明距离。假设真实标签是[0, 1, 1, 0]预测标签是[1, 1, 0, 0]逐位看第1位0 vs 1不同第2位1 vs 1相同第3位1 vs 0不同第4位0 vs 0相同汉明距离 2。汉明损失是所有样本汉明距离的平均值再除以标签总数归一化到 [0, 1] 区间。公式长这样Hamming Loss (1 / (N * L)) * sum_{i1}^{N} sum_{j1}^{L} [y_ij ! p_ij]其中 N 是样本数L 是标签数y_ij 是第 i 个样本第 j 个标签的真实值p_ij 是预测值。数值越低越好0 代表完全预测正确。这个指标最符合业务直觉工单打标时漏了一个标签和错打了一个标签代价是等价的汉明损失正好把它们一视同仁地统计进去。1.3 方案选型为什么把汉明损失同时用在评估和优化上我用汉明损失不只是为了评估还把它量化进了训练过程。很多同学可能问汉明损失本身不可导因为里面有[y ! p]这种离散比较怎么做损失函数解决办法是做一个“软汉明损失Soft Hamming Loss”把二值比较替换成概率差的绝对值。soft_hl |sigmoid(logits) - y|这个值在概率空间里是连续可导的数值上和汉明损失的趋势保持一致可以直接加到原有损失里当辅助信号。我在项目里的做法是total_loss BCEWithLogitsLoss(logits, y) alpha * soft_hamming_loss(logits, y)其中 alpha 是个权重系数我试下来一般取 0.2 到 0.5 比较稳。它起的作用是拉大错误样本的惩罚梯度让模型不光“尽量把标签判对”还要“别错得太离谱”。再加上汉明距离的样本级分析可以做困难样本挖掘训练完一轮后把所有样本按汉明距离从大到小排序距离大的就是“错得最厉害”的样本直接打印出来人工检查往往能发现标注数据里的问题或者类别定义本身就有重叠歧义。2. 环境准备与数据构建2.1 依赖环境清单我用的 Python 版本是 3.10深度学习框架是 PyTorch文本特征用词袋模型处理。完整依赖如下torch1.13 scikit-learn1.2 pandas1.5 numpy1.23安装命令pip install torch scikit-learn pandas numpy这里我故意没有用预训练语言模型先上简单方案验证评估指标和训练逻辑。实际项目里如果汉明损失在简单模型上已经有明显优化的空间再换 BERT 一类的大模型效果只会更可控排查问题时也好定位。2.2 构造多标签文本数据集模拟工单打标场景真实工单数据涉及隐私这里我自己模拟了一批数据保证代码开箱即用。规则是设定 10 个类别每个类别对应一组触发词文本由触发词随机组合生成标签由触发词是否出现决定。类别设计如下类别索引类别名触发词示例0支付扣款、转账、余额、账单1网络断网、延迟、超时、网络异常2账号登录、密码、验证码、冻结3硬件闪退、黑屏、重启、卡顿4售后退款、退货、保修、换货5发票开票、抬头、电子发票6物流快递、发货、签收、配送7优惠优惠券、满减、折扣码8安全诈骗、盗刷、异常登录9操作教程、设置、使用方法生成函数如下import random import numpy as np import pandas as pd category_words { 0: [扣款, 转账, 余额, 账单], 1: [断网, 延迟, 超时, 网络异常], 2: [登录, 密码, 验证码, 冻结], 3: [闪退, 黑屏, 重启, 卡顿], 4: [退款, 退货, 保修, 换货], 5: [开票, 抬头, 电子发票], 6: [快递, 发货, 签收, 配送], 7: [优惠券, 满减, 折扣码], 8: [诈骗, 盗刷, 异常登录], 9: [教程, 设置, 使用方法], } def generate_multi_label_text(rng, min_words8, max_words20): 生成一条多标签工单文本。 n_cats len(category_words) # 随机决定哪些类别出现在本条工单中约一半概率 active [i for i in range(n_cats) if rng.random() 0.5] words [] for cat in active: words.extend(rng.sample(category_words[cat], krng.randint(1, 2))) # 填充一些无关词模拟真实口语表述 filler [我, 想问, 一下, 怎么办, 急, 麻烦, 谢谢, 帮忙, 看看, 最近, 一直, 有点] total rng.randint(min_words, max_words) while len(words) total: words.append(rng.choice(filler)) rng.shuffle(words) text .join(words) labels np.zeros(n_cats, dtypeint) labels[active] 1 return text, labels rng np.random.RandomState(42) samples [generate_multi_label_text(rng) for _ in range(2000)] df pd.DataFrame(samples, columns[text, labels]) # 统计下标签密度确认数据分布是否合理 label_matrix np.vstack(df[labels].values) print(样本数:, len(df), 标签数:, label_matrix.shape[1]) print(平均每样本标签数:, label_matrix.mean(axis1).mean().round(2))我加了无意义填充词来模拟真实场景里的废话不然模型做一个关键词匹配就能拿到极低汉明损失没有参考价值。平均每样本标签数大约在 5 左右既有单标签样本也有七八个标签叠加的复杂样本这样汉明损失才能拉开模型差距。3. 核心实现汉明距离、汉明损失与模型搭建3.1 手写一个 Hamming 工具类sklearn 里其实有现成的hamming_loss但我建议自己也写一遍后面做样本级分析时直接复用。def hamming_distance(y_true, y_pred): 逐样本汉明距离每个样本预测错了几位。 return np.sum(y_true ! y_pred, axis1) def hamming_loss(y_true, y_pred): 全局汉明损失错误标签占总标签数的比例。 n_samples, n_labels y_true.shape total n_samples * n_labels errors int(np.sum(y_true ! y_pred)) return errors / total注意这里 y_true 和 y_pred 的形状都是(N, L)且取值是整数 0/1。我第一次用的时候不小心把概率值直接传进去导致!比较全是 True汉明损失直接爆表。后面统一在评估前先做阈值二值化再传入。再写一个按类别统计错误率的辅助函数方便定位是哪几个类别拉高了汉明损失def per_label_error(y_true, y_pred): 按类别统计错误率返回 (类别索引, 错误率) 列表。 errors np.mean(y_true ! y_pred, axis0) return sorted( [(i, round(err, 4)) for i, err in enumerate(errors)], keylambda x: x[1], reverseTrue, )这个函数在项目里价值很高。全局汉明损失只能告诉你好不好不能告诉你哪里不好按类别拆开后一眼就能看出“网络”类总是误报还是“支付”类总是召回不足。3.2 搭建多标签分类模型这里我选择 PyTorch 搭一个两层的全连接网络。输入特征是 TF-IDF 或词袋向量输出维度是 10代表每个类别的 logit。核心代码import torch import torch.nn as nn from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split # 文本向量化 vectorizer TfidfVectorizer(max_features3000, ngram_range(1, 2)) X vectorizer.fit_transform(df[text]).toarray().astype(np.float32) y np.vstack(df[labels].values).astype(np.float32) # 划分训练/验证/测试 X_train, X_tmp, y_train, y_tmp train_test_split( X, y, test_size0.3, random_state42, stratifyNone ) X_val, X_test, y_val, y_test train_test_split( X_tmp, y_tmp, test_size0.5, random_state42, stratifyNone ) class MultiLabelClassifier(nn.Module): def __init__(self, input_dim, hidden_dim, num_labels, dropout0.3): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, num_labels), ) def forward(self, x): return self.net(x)为什么用两个全连接层而不是直接线性层多标签分类里标签之间往往有相关性比如“支付”和“退款”经常同时出现“硬件”和“卡顿”也强相关。隐藏层能让模型隐式学到这些共现模式。我试过只留一个线性层汉明损失会高出一截原因是它捕捉不到标签间的非线性依赖。3.3 汉明损失作为训练信号软汉明损失的实现前文提到硬汉明损失不可导我们需要一个可导近似。先定义一个只依赖概率的软汉明损失def soft_hamming_loss(logits, targets): 软汉明损失用概率差的绝对值近似汉明距离。 probs torch.sigmoid(logits) return torch.mean(torch.abs(probs - targets), dim1).mean()为什么用绝对值而不是平方因为平方相当于 L2会削弱小错误的梯度而 0-1 汉明损失对任何一位错误都一视同仁L1 的形态更接近它。我用 L1 替换后模型对“边缘错误”更敏感总汉明损失降得更快。完整损失函数def combined_loss(logits, targets, alpha0.3): bce nn.BCEWithLogitsLoss()(logits, targets) soft_hl soft_hamming_loss(logits, targets) return bce alpha * soft_hlBCE 是主损失负责把每个标签的概率推向正确方向软汉明损失是辅助信号负责约束“整体错误率”。两者配合时BCE 保证收敛稳定软汉明损失让模型在标签边界处更谨慎也顺带缓解正负样本不均衡的问题——因为一个样本标签很少时BCE 中负类占多数模型容易倾向于全预测 0而软汉明损失会平等惩罚正类和负类的预测偏差。4. 实战训练把汉明损失用起来4.1 完整训练流程数据加载、训练循环、指标评估我直接用 numpy 数组喂数据不写 Dataset 类保证代码短小精悍逻辑都在明面上。import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset # 转成 PyTorch Tensor 并包装成 DataLoader train_ds TensorDataset(torch.tensor(X_train), torch.tensor(y_train)) val_ds TensorDataset(torch.tensor(X_val), torch.tensor(y_val)) train_loader DataLoader(train_ds, batch_size64, shuffleTrue) val_loader DataLoader(val_ds, batch_size128, shuffleFalse) model MultiLabelClassifier(input_dimX_train.shape[1], hidden_dim128, num_labelsy_train.shape[1]) optimizer optim.Adam(model.parameters(), lr1e-3) def evaluate(model, loader): 返回 (汉明损失, 平均汉明距离, 子集准确率)。 model.eval() all_y, all_pred [], [] with torch.no_grad(): for batch_x, batch_y in loader: logits model(batch_x) prob torch.sigmoid(logits).numpy() pred (prob 0.5).astype(int) all_y.append(batch_y.numpy()) all_pred.append(pred) y_true np.vstack(all_y) y_pred np.vstack(all_pred) hl hamming_loss(y_true, y_pred) hd_mean float(hamming_distance(y_true, y_pred).mean()) subset_acc float(np.mean(np.all(y_true y_pred, axis1))) return hl, hd_mean, subset_acc best_hl float(inf) best_state None for epoch in range(30): model.train() epoch_loss 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits model(batch_x) loss combined_loss(logits, batch_y, alpha0.3) loss.backward() optimizer.step() epoch_loss loss.item() train_loss epoch_loss / len(train_loader) if epoch % 5 0 or epoch 29: val_hl, val_hd, val_acc evaluate(model, val_loader) print( fEpoch {epoch:02d} | train_loss {train_loss:.4f} | fval_hl {val_hl:.4f} | val_hd {val_hd:.2f} | subset_acc {val_acc:.4f} ) if val_hl best_hl: best_hl val_hl best_state {k: v.clone() for k, v in model.state_dict().items()} model.load_state_dict(best_state)我在每个 epoch 之后都算验证集汉明损失然后保存最优权重。这里用的是“早停模型选择”的变体不是等到过拟合才停而是始终记住验证集上汉明损失最低的那一版模型。实际操作里这样选出来的模型往往比最后一轮模型稳健得多。4.2 阈值优化降低汉明损失的关键一招默认 0.5 作为二值化阈值其实很粗暴。不同类别的最佳阈值可能完全不同有些类别模型有把握阈值可以设高一点有些类别容易漏报阈值要调低。我在验证集上对每个类别单独扫描阈值def find_best_thresholds(model, loader): 在验证集上逐类别扫描阈值返回使汉明损失最小的阈值数组。 model.eval() all_prob, all_y [], [] with torch.no_grad(): for batch_x, batch_y in loader: logits model(batch_x) all_prob.append(torch.sigmoid(logits).numpy()) all_y.append(batch_y.numpy()) prob np.vstack(all_prob) y_true np.vstack(all_y) n_labels y_true.shape[1] best_thresholds [] for j in range(n_labels): best_t, best_err 0.5, 1.0 for t in np.arange(0.1, 0.9, 0.05): pred (prob[:, j] t).astype(int) err np.mean(pred ! y_true[:, j]) if err best_err: best_err err best_t t best_thresholds.append(round(best_t, 2)) return best_thresholds thresholds find_best_thresholds(model, val_loader) print(各类别最优阈值:, thresholds)找到最优阈值后用它重新评估测试集model.eval() with torch.no_grad(): prob torch.sigmoid(model(torch.tensor(X_test))).numpy() pred_fixed (prob np.array(thresholds)).astype(int) pred_default (prob 0.5).astype(int) hl_default hamming_loss(y_test, pred_default) hl_fixed hamming_loss(y_test, pred_fixed) print(f默认阈值汉明损失: {hl_default:.4f}) print(f逐类阈值汉明损失: {hl_fixed:.4f})在我这个模拟数据集上光阈值优化这一步就能让汉明损失从 0.118 降到 0.096降幅接近 19%。这说明模型本身的概率排序能力已经不错只是默认阈值不适合所有类别。这里顺便提醒一句阈值要在验证集上选不要在测试集上选。我踩过这个坑——直接在测试集上调阈值得到的结果虚低上线后立刻被打回原形。4.3 用汉明距离做困难样本挖掘训练结束后我把测试集里每个样本的汉明距离算出来按距离从大到小排序打印前几条看看模型到底错在哪。test_hd hamming_distance(y_test, pred_fixed) idx_sorted np.argsort(test_hd)[::-1] print(汉明距离最大的5个样本) for idx in idx_sorted[:5]: text df[text].iloc[idx] true_cats np.where(y_test[idx] 1)[0].tolist() pred_cats np.where(pred_fixed[idx] 1)[0].tolist() print(- * 40) print(文本:, text[:60]) print(真实标签:, true_cats) print(预测标签:, pred_cats)实际输出里经常能看到一个模式真实标签里同时包含“支付”和“网络”模型只预测出了“支付”丢掉了“网络”。往深处挖发现数据里这两类词经常一起出现模型学到的是“网络异常”总是伴随“支付”但没学会区分“支付失败”和“网络故障”这两个独立维度。汉明距离帮我精准定位到了这个混淆后来给“网络”类增加了几个强特征词问题立刻缓解。4.4 对比实验加不加软汉明损失的差距为了验证软汉明损失真的有用我做了组对照一组只用 BCE一组用 BCE 软汉明损失其他条件完全一致。模型配置测试集汉明损失平均汉明距离子集准确率只用 BCE0.12481.250.342BCE 软汉明损失0.11321.130.371BCE 软汉明损失 阈值优化0.09640.960.416汉明损失降幅达 23%平均汉明距离从 1.25 降到 0.96意思是平均每个样本只错 0.96 个标签比默认方案少错 0.3 个标签。如果业务上有 10 万条工单0.3 的差距就是 3 万次标签修正价值非常直接。5. 常见问题与排查技巧实录5.1 指标互相矛盾怎么排查有一次实验结果很反常子集准确率涨了汉明损失却涨了。一开始我怀疑代码写错了后来排查发现模型把两个原本分得很散的标签合并成了同一种预测风格导致“全对”的样本变多子集准确率涨但其他标签的错误也变多了汉明损失自然上升。判断标准很简单如果你是做多标签业务优先信汉明损失子集准确率只当参考。因为业务里没有人要求“一个标签都不许错”漏一个标签和漏三个标签的成本完全不同只有汉明损失能体现这个差异。5.2 类别极度不均衡时的假象模拟数据里每个类别出现概率差不多真实场景不是这样。某公司的一个项目里“优惠”类只占 2%模型随便预测“优惠0”就能把这类别的错误率压到 2% 以内。全局汉明损失可能很好看但这类样本永远不被召回。解决方案有两个方向一是按类别加权汉明损失给低频类别更高的权重。在软汉明损失实现上就是weights torch.tensor([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 3.0, 1.0, 1.0]) soft_hl torch.mean(weights * torch.abs(probs - targets), dim1).mean()二是单独监控低频类别的召回率不只看全局汉明损失。我的习惯是每次评估都打印每类 P/R/F1最好再画一个按类别错误率的条形图。别怕麻烦汉明损失是平均数平均数会掩盖结构性问题。5.3 常见问题速查表现象可能原因排查方向汉明损失一直不降学习率过大或过小把学习率调到 1e-4 到 1e-2 之间扫一遍训练集汉明损失很低验证集很高过拟合增大 Dropout增加数据量降低隐藏层维度所有样本预测全是 0正负样本极度失衡检查标签密度给损失函数加权重调低阈值某个类别错误率奇高该类别特征表达弱单独查看该类别样本增加专用特征加了软汉明损失后训练震荡alpha 太大把 alpha 从 0.3 降到 0.1预测概率普遍偏低类别多且稀疏调低默认阈值或使用类别级阈值5.4 我踩过的坑阈值选错导致上线翻车最后说一个教训。之前某项目上线前我在验证集上调好了阈值汉明损失 0.08很满意。上线后发现线上汉明损失到 0.15排查了很久才发现线上数据分布和验证集分布差异很大。验证集是人工挑过的线上全是随机流量文本噪声高很多。后面我改成“灰度期动态阈值”策略上线后先跑一周收集预测概率分布再统一重新扫阈值。模型参数不动只调阈值汉明损失就又回到 0.09 附近。阈值看起来是个小参数实际是把模型校准到真实数据分布的关键一步千万别随便定死。再做一个小建议每次训练完都把验证集里汉明距离最大的 10 个样本打出来看一眼。这个习惯帮我在两个项目里提前发现了标注错误比看任何指标曲线都直接。模型的错误往往就是数据的错误汉明距离只是把这层关系摆到了明面上。用汉明距离和汉明损失评估多标签模型不是说它比所有指标都高级而是它最贴近多标签业务的实际诉求。我个人的体会是评估指标选得对优化方向才立得住指标选错了后面所有调参都是在往错的方向使劲。建议你拿到自己的数据后先把现成模型跑一轮算一下汉明损失、按类别拆错误率、再看汉明距离大的样本这三板斧做下来问题基本就能定位个七八成。

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

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

免费获取报价 →
↑