☰
多标签文本分类的评估与优化:用汉明损失替代准确率
2026/10/22 5:07:22 网站建设 项目流程

做多标签文本分类时,最容易踩的坑就是把单标签那套“准确率”思维直接搬过来用。我接到过不少这类需求,最典型的是工单自动打标:一条工单可能同时命中“故障”和“支付”两个标签,也可能一个都不该命中。你在这种场景下算准确率,模型几乎永远“不准”,因为要求所有标签都预测对才算对。真正能反映模型好坏的是汉明损失(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_{i=1}^{N} sum_{j=1}^{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,文本特征用词袋模型处理。完整依赖如下:

torch>=1.13 scikit-learn>=1.2 pandas>=1.5 numpy>=1.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_words=8, max_words=20): """生成一条多标签工单文本。""" 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], k=rng.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, dtype=int) 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(axis=1).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, axis=1) 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, axis=0) return sorted( [(i, round(err, 4)) for i, err in enumerate(errors)], key=lambda x: x[1], reverse=True, )

这个函数在项目里价值很高。全局汉明损失只能告诉你好不好,不能告诉你哪里不好;按类别拆开后,一眼就能看出“网络”类总是误报,还是“支付”类总是召回不足。

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_features=3000, 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_size=0.3, random_state=42, stratify=None ) X_val, X_test, y_val, y_test = train_test_split( X_tmp, y_tmp, test_size=0.5, random_state=42, stratify=None ) class MultiLabelClassifier(nn.Module): def __init__(self, input_dim, hidden_dim, num_labels, dropout=0.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), dim=1).mean()

为什么用绝对值而不是平方?因为平方(相当于 L2)会削弱小错误的梯度,而 0-1 汉明损失对任何一位错误都一视同仁,L1 的形态更接近它。我用 L1 替换后,模型对“边缘错误”更敏感,总汉明损失降得更快。

完整损失函数:

def combined_loss(logits, targets, alpha=0.3): bce = nn.BCEWithLogitsLoss()(logits, targets) soft_hl = soft_hamming_loss(logits, targets) return bce + alpha * soft_hl

BCE 是主损失,负责把每个标签的概率推向正确方向;软汉明损失是辅助信号,负责约束“整体错误率”。两者配合时,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_size=64, shuffle=True) val_loader = DataLoader(val_ds, batch_size=128, shuffle=False) model = MultiLabelClassifier(input_dim=X_train.shape[1], hidden_dim=128, num_labels=y_train.shape[1]) optimizer = optim.Adam(model.parameters(), lr=1e-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, axis=1))) 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, alpha=0.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( f"Epoch {epoch:02d} | train_loss {train_loss:.4f} | " f"val_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.342
BCE + 软汉明损失0.11321.130.371
BCE + 软汉明损失 + 阈值优化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), dim=1).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 个样本打出来看一眼。这个习惯帮我在两个项目里提前发现了标注错误,比看任何指标曲线都直接。模型的错误往往就是数据的错误,汉明距离只是把这层关系摆到了明面上。

用汉明距离和汉明损失评估多标签模型,不是说它比所有指标都高级,而是它最贴近多标签业务的实际诉求。我个人的体会是,评估指标选得对,优化方向才立得住;指标选错了,后面所有调参都是在往错的方向使劲。建议你拿到自己的数据后,先把现成模型跑一轮,算一下汉明损失、按类别拆错误率、再看汉明距离大的样本,这三板斧做下来,问题基本就能定位个七八成。

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

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

立即咨询