很多做过多标签分类的开发者都有一种直觉:分类器训练时用交叉熵,评估时看 Jaccard,两个数字之间经常“对不上”。训练损失一直接近 0,线上 Jaccard 却迟迟上不去;或者线下调阈值能让 Jaccard 涨 3 个点,一换测试集又吐回去。我在读到Exponential Convex Calibration Dimension for the Multi-Label Jaccard Measure这类理论主题时,最大的感受是:我们缺的不是更复杂的模型,而是对“代理损失、决策阈值、评估指标三者之间关系”的系统化理解。
本文尽量用较少的公式,把多标签 Jaccard 度量、凸校准维度、指数型凸代理损失这些概念串成一条线,并给出可运行的 Python 代码。适合正在做多标签分类工程、想读理论论文但需要中文铺垫的读者。读完后,你会明白 Jaccard 为什么难优化、代理损失为什么需要“校准”,以及从论文标题回到代码层面时应该关注哪些变量。
1. 背景与核心概念
1.1 多标签分类任务与 Jaccard 度量
多标签分类在业务中非常常见。一张商品图可能同时命中“连衣裙”和“夏季新品”;一篇文章可能同时属于“科技”和“财经”;一个工单可能同时被标记为“网络故障”和“售后投诉”。这些任务的共同特点是:每个样本的标签数量不固定,标签之间也不互斥。多标签分类的目标,是给每个样本预测一个标签集合。
评估这种预测集合的好坏,Jaccard 是一种非常自然的做法。Jaccard 的计算逻辑来自集合论,衡量的是两个集合的交集与并集之间的比例。如果某个样本的真实标签集合是 Y,预测标签集合是 Ŷ,那么 Jaccard 指标可以写成:
Jaccard = |Y ∩ Ŷ| / |Y ∪ Ŷ|分子是两个集合共有的标签数量,分母是“只要出现在其中一个集合里的标签数量”。如果真实标签和预测标签完全相同,Jaccard 等于 1;如果两者完全没有重合部分,Jaccard 等于 0。实际应用中,如果两个集合都为空,通常会约定 Jaccard 为 1。这个约定会影响后面对代码和损失函数的设计,值得单独注意。
在多标签论文和竞赛中,常见做法是把每个样本的 Jaccard 计算出来,再求平均。也就是说,所有样本的 Jaccard 分数取均值作为一种整体指标。部分场景也会把“多标签 Jaccard”定义成基于所有样本合并后的混淆矩阵来计算,也就是全局 Jaccard。本文不涉及两者之间的争论,只讨论按样本平均的情况,因为这是最常用、也最能体现单样本决策难度的设置。
1.2 一个直观的计算样例
看几个具体例子会更容易理解。假设某个多标签数据集一共有 4 个候选标签,编号为 1 到 4。真实标签和预测标签分别如下表所示:
| 真实标签 Y | 预测标签 Ŷ | 交集 | 并集 | Jaccard |
|---|---|---|---|---|
| {1, 2, 3} | {1, 2} | 2 | 3 | 0.667 |
| {1, 2, 3} | {1, 4} | 1 | 4 | 0.250 |
| {1} | {2, 3} | 0 | 3 | 0.000 |
| {1, 2} | {1, 2} | 2 | 2 | 1.000 |
| 空集 | 空集 | 0 | 0 | 1.000(约定) |
其中第四行是最好的完整命中情况。第五行需要特别说明:在集合相似度计算中,两个空集合的交集和并集都是空集,分母为 0,无法直接相除。如果某类任务允许“真实标签为空”,并且模型也预测为空,那么从业务上看,这个样本应该得满分,否则模型永远不敢预测空集。因此工程上通常把这种情况约定为 1.0。
1.3 直接用准确率可能不合适
很多初学者会问:多标签分类为什么不用准确率?准确率虽然直观,但它把“大多数标签都预测对了”和“个别标签预测错了”视作完全不同的两类情况。例如真实标签是 {1, 2, 3},模型预测 {1, 2},标签级别的准确率可能达到 2/3 或接近 1,取决于是否把负类也计入。问题是,这种指标并没有直接回答业务关心的问题:预测出来的集合和真实集合有多像。
Jaccard 有一个优点:它对“预测过多”和“预测过少”都同样敏感。还是以 {1, 2, 3} 为例,预测 {1, 2} 会扣分,预测 {1, 2, 3, 4} 同样会扣分。因为并集变大了,比值就会下降。这种对称惩罚比单纯看召回率或精确率更接近集合级评估的直觉。
因此,当业务方要求“把要打的标签尽可能完整地打出来,同时不要乱打太多无关标签”时,Jaccard 通常比准确率更能反映需求。
2. 为什么直接优化 Jaccard 行不通
2.1 Jaccard 天然是离散的、不光滑的
机器学习模型最终输出的不是离散标签,而是连续分数。例如一个模型可能输出:
标签 1:0.91 标签 2:0.45 标签 3:0.30要得到预测集合,常见做法是设置一个阈值,例如只把分数大于 0.5 的标签选进来。问题在于,Jaccard 是在“选进来之后”的离散集合上计算的。这个从连续分数到离散集合的映射存在一个跳变:只要某一标签的分数从 0.49 变成 0.51,预测集合就变了,Jaccard 可能从 0.3 跳到 1.0。这种阶梯状、不可导的指标很难直接用梯度下降优化。
理论上你可以把预测集合本身视作一个变量来搜索,但候选集合数量是 2^L。如果候选标签 L=20,就已经有上百万个候选集合;如果 L=100,枚举几乎不可行。所以直接用 Jaccard 作为损失函数,很快就会遇到计算和优化双重障碍。
2.2 Jaccard 不可分解
二分类交叉熵、平方误差这类损失可以拆分成单个标签上的损失之和。也就是说,每个标签的梯度可以独立计算,互相不干扰。Jaccard 做不到这一点。因为 Jaccard 的分子是集合交集,分母是集合并集,两者都依赖“整组标签同时被选择”的情况。
举个最简单的例子:真实标签集合是 {1, 2}。模型把标签 1 的分数排得再高,也不能单独决定 Jaccard 大小;它还要看标签 2 是否被选上,以及是否存在其他被误选进去的标签。如果模型多选了一个标签 3,这个额外的误报会让分母变大,从而影响标签 1 和标签 2 带来的收益。标签与标签之间“绑定”在一起,这正是 Jaccard 不适合简单拆分成多个二分类问题的根本原因。
2.3 最优决策规则与“固定阈值”不完全等价
有些论文会把多标签问题做得很简单:先为每个标签训练一个二分类器,再用 0.5 作为阈值。这种做法从工程上能跑通,但它隐含一个假设:每个标签的校准概率都能直接决定集合内是否包含该标签。现实情况往往不是这样。
给定一组标签分数,例如:
标签 A:0.80,标签 B:0.79如果固定阈值取 0.5,两个标签都会被选进预测集合。但假设这个样本的真实标签只有一个,那么无论选 A 还是 B,预测集合都会包含一个假阳性标签。此时最优决策可能是“只取分数最高的那个标签”,而不是“把大于阈值的标签全部取出”。这说明在 Jaccard 这种集合级指标下,标签之间会竞争有限预算。
这也是为什么单纯让每个标签的预测概率更准,并不等于 Jaccard 更高。模型需要学习的是整体排序和集合规模之间的配合。这种配合关系很难由固定阈值规则自动完成。
3. 凸校准维度与代理损失的关系
3.1 代理损失的“校准”是什么
既然 Jaccard 不能直接作为损失函数优化,研究者通常会设计一个代理损失。这个代理损失是连续的、可微的,最好还是凸的,然后希望在模型训练完成后,代理损失取到较小值的同时,真实关心的 Jaccard 也能取得较高分数。
但“希望”不等于一定成立。更正式的研究方式是问:如果样本无限多、模型能力无限强,当模型在某个代理损失上达到全局最优时,它是否一定会在真实指标上也达到全局最优?如果答案是肯定的,就说这个代理损失关于目标指标是校准的,也常被称为一致性。
用更口语的话说:代理损失只是“替身”。替身要可靠,不能只是在当前数据集上看起来相关,而是要求在理论层面保证最优化二者不会南辕北辙。交叉熵对于普通 0/1 损失具有这种校准性质,所以二分类里用交叉熵很少出错。但 Jaccard 这种集合级指标不存在这么简单的替身。
3.2 校准维度是个怎样的概念
校准维度想衡量的是:为了从一个连续评分函数恢复到最优离散预测,至少需要引入多少“决策自由度”。
二分类问题可以这样理解:模型输出一个连续分数 f(x),最优预测是看分数是否为正值。这里的决策自由度很小,基本可以看作一个符号判断,也就是一维阈值判断。换成 top-K 分类问题时,模型输出 L 个分数,最优预测通常从分数最高的 K 个标签里产生,此时额外多了一个 K 的决策。如果每个样本的最优 K 都固定,决策规则依然很简洁;但真实数据中每个样本的真实标签数可能不同,最优 K 就会随样本变化。
多标签 Jaccard 比 top-K 更复杂。Jaccard 的惩罚机制不仅决定“选几个”,还决定“哪些不能漏、哪些不能多”。理论上,最优预测集合可能并不严格等价于“分数最高的那 K 个标签”,因为类别不平衡、标签相关性、阈值位置都会影响最终集合。Jaccard 对应的最优决策规则,可能需要更多维度的信息才能描述清楚。凸校准维度这个概念,就是为了量化这种“额外自由度”而提出的。
需要说明的是,这里的“维度”不是指输入特征维度,也不是神经网络隐层神经元数量,而是决策规则本身的自由度。它关心的是:如果代理损失是一个凸函数,为了保证代理损失最小化结果能映射回真实 Jaccard 最优结果,这个凸代理损失至少需要多复杂的决策空间。
3.3 Exponential Convex Calibration Dimension 想表达什么
看到Exponential Convex Calibration Dimension for the Multi-Label Jaccard Measure这个标题时,可以先不急着把它当作某个 API 或算法名。它更像一条理论研究线索:研究者在讨论,对于多标签 Jaccard 指标,如果使用指数型的凸代理损失,那么校准维度到底会是多少,以及这种维度是否可以控制在一个可处理的范围。
Exponential 通常让人联想到指数函数,例如:
exp(-z)这种形式有几个很好的性质:它是凸函数、光滑、处处可导,并且对分类错误样本会施加较大的梯度压力。把指数损失作为代理损失,在很多二分类模型中被证明可以产生一种提升式的加权训练效果。因此,当标题中出现 Exponential 时,可以猜测研究者讨论的是这类带指数表达式的凸代理损失。
不过我不建议在没看到原文时把 Exponential 的含义写死。它既可能指指数损失函数族,也可能指证明过程中出现的指数级校准维度上界。更稳妥的理解是:这篇文章的核心问题在于 Jaccard 度量下,凸代理损失的整体结构如何决定最优决策的复杂性。本文后续代码不实现完整证明,而是帮助建立这种损失的几何和计算直觉。
4. 环境准备与基础代码
4.1 环境与安装
本文示例以 Python 为主,需要 numpy。如果你希望直接调用 sklearn 里的 Jaccard 计算函数,还需要安装 scikit-learn。安装命令如下:
pip install numpy scikit-learn版本不需要刻意追求最新。通常来说,Python 3.9 及以上、numpy 1.20 以上、scikit-learn 1.0 以上的环境都可以正常运行。如果你用的是旧版本,请留意jaccard_score函数里average参数在不同版本中的默认行为可能存在差异。
建议代码文件结构如下:
multi_label_jaccard/ ├── jaccard_utils.py └── demo_sweep.py其中jaccard_utils.py放公共计算函数,demo_sweep.py放阈值扫描实验。保持函数独立有利于后续扩展,也方便直接复制到实际项目中使用。
4.2 单样本 Jaccard 计算函数
先从最简单的场景开始:一个样本的真实标签和预测标签都用 Python 集合表示。计算 Jaccard 可以直接写成下面这样。
# 文件路径:jaccard_utils.py def jaccard_on_set(y_true, y_pred): """接收两个标签集合,返回单样本 Jaccard 分数。 如果两个集合都为空,按惯例返回 1.0。 """ if not y_true and not y_pred: return 1.0 inter = len(y_true & y_pred) union = len(y_true | y_pred) if union == 0: return 1.0 return inter / union if __name__ == "__main__": print(jaccard_on_set({1, 2, 3}, {1, 2})) # 0.666... print(jaccard_on_set({1, 2, 3}, {4})) # 0.0 print(jaccard_on_set(set(), set())) # 1.0这段代码是整个实验的基础。需要注意,两个集合都为空的情况非常特殊,不能直接执行除法,否则会出现ZeroDivisionError。从业务角度也需要仔细约定,空预测命中空真实标签到底应该算 0 还是算 1。大部分文档和论文实现中,这种情况下 Jaccard 会被设为 1,因为它代表完全一致。
4.3 批量预测中的向量化 Jaccard
实际建模时,标签通常以 0/1 矩阵形式出现。每个样本用长度为 L 的向量表示,其中 1 表示该样本包含这个标签。假设有三个样本、六个候选标签,真实标签和模型分数如下:
import numpy as np y_true = np.array([ [1, 1, 0, 0, 0, 0], [0, 1, 1, 1, 0, 0], [0, 0, 0, 1, 1, 1], ]) scores = np.array([ [0.7, 0.6, 0.5, 0.4, 0.3, 0.2], [0.2, 0.9, 0.8, 0.7, 0.6, 0.1], [0.1, 0.2, 0.3, 0.5, 0.6, 0.9], ])我们可以按 top-K 规则生成预测集合:取每个样本分数最高的 K 个标签作为预测集合。这样做的意义是,假设模型只负责输出一个可信排序,最终集合规模由外部参数 K 控制。代码如下:
def average_jaccard_at_k(y_true, scores, k): """按 top-K 规则生成预测集合并计算平均 Jaccard。""" n_samples, n_labels = y_true.shape total_jaccard = 0.0 for i in range(n_samples): pred = np.zeros(n_labels, dtype=int) if k > 0: top_k_indices = np.argsort(scores[i])[::-1][:k] pred[top_k_indices] = 1 intersection = int(np.sum(y_true[i] & pred)) union = int(np.sum((y_true[i] | pred))) if union == 0: total_jaccard += 1.0 else: total_jaccard += intersection / union return total_jaccard / n_samples for k in range(0, 7): avg = average_jaccard_at_k(y_true, scores, k) print(f"k={k}, average_jaccard={avg:.4f}")在这组手工构造的数据上,不同 K 的结果会非常不同。直观的预期是,K 很小时模型会漏掉一部分真实标签,Jaccard 偏低;K 等于标签总数时,预测集合会包含很多误报标签,Jaccard 同样偏低。因此一定存在某个居中的 K,使得平均 Jaccard 最高。你可以把这类结果理解为一个简单结论:Jaccard 的好坏不只看排序质量,还看集合规模如何选择。
4.4 使用 sklearn 快速计算
如果你的数据已经是 0/1 矩阵,并且想快速和 sklearn 官方实现作对比,可以直接使用jaccard_score。这里必须指定average='samples',表示对每个样本先算 Jaccard,再取平均。
from sklearn.metrics import jaccard_score y_pred = np.array([ [1, 1, 0, 0, 0, 0], [0, 1, 1, 0, 0, 0], [0, 0, 0, 1, 1, 1], ]) # average='samples' 表示先算每个样本的 Jaccard,再对所有样本取平均 score = j