CAN:借助先验分布提升分类性能的简单后处理技巧
By 苏剑林 | 2021-10-22 | 148238位读者 |顾名思义,本文将会介绍一种用于分类问题的后处理技巧——CAN(Classification with Alternating Normalization),出自论文《When in Doubt: Improving Classification Performance with Alternating Normalization》。经过笔者的实测,CAN确实多数情况下能提升多分类问题的效果,而且几乎没有增加预测成本,因为它仅仅是对预测结果的简单重新归一化操作。
有趣的是,其实CAN的思想是非常朴素的,朴素到每个人在生活中都应该用过同样的思想。然而,CAN的论文却没有很好地说清楚这个思想,只是纯粹形式化地介绍和实验这个方法。本文的分享中,将会尽量将算法思想介绍清楚。
思想例子 #
假设有一个二分类问题,模型对于输入$a$给出的预测结果是$p^{(a)} = [0.05, 0.95]$,那么我们就可以给出预测类别为$1$;接下来,对于输入$b$,模型给出的预测结果是$p^{(b)}=[0.5,0.5]$,这时候处于最不确定的状态,我们也不知道输出哪个类别好。
但是,假如我告诉你:1、类别必然是0或1其中之一;2、两个类别的出现概率各为0.5。在这两点先验信息之下,由于前一个样本预测结果为1,那么基于朴素的均匀思想,我们是否更倾向于将后一个样本预测为0,以得到一个满足第二点先验的预测结果?
这样的例子还有很多,比如做10道选择题,前9道你都比较有信心,第10题完全不会只能瞎蒙,然后你一看发现前9题选A、B、C的都有就是没有一个选D的,那么第10题在蒙的时候你会不会更倾向于选D?
这些简单例子的背后,有着跟CAN同样的思想,它其实就是用先验分布来校正低置信度的预测结果,使得新的预测结果的分布更接近先验分布。
不确定性 #
准确来说,CAN是针对低置信度预测结果的后处理手段,所以我们首先要有一个衡量预测结果不确定性的指标。常见的度量是“熵”(参考《“熵”不起:从熵、最大熵原理到最大熵模型(一)》),对于$p=[p_1,p_2,\cdots,p_m]$,定义为:
\begin{equation}H(p) = -\sum_{i=1}^m p_i\log p_i\end{equation}
然而,虽然熵是一个常见选择,但其实它得出的结果并不总是符合我们的直观理解。比如对于$p^{(a)}=[0.5,0.25,0.25]$和$p^{(b)}=[0.5,0.5,0]$,直接套用公式得到$H(p^{(a)}) > H(p^{(b)})$,但就我们的分类场景而言,显然我们会认为$p^{(b)}$比$p^{(a)}$更不确定,所以直接用熵还不够合理。
一个简单的修正是只用前top-$k$个概率值来算熵,不失一般性,假设$p_1,p_2,\cdots,p_k$是概率最高的$k$个值,那么
\begin{equation}H_{\text{top-}k}(p) = -\sum_{i=1}^k \tilde{p}_i\log \tilde{p}_i\end{equation}
其中$\tilde{p}_i=p_i\Big/ \sum\limits_{i=1}^k p_i$。为了得到一个0~1范围内的结果,我们取$H_{\text{top-}k}(p)/\log k$为最终的不确定性指标。
算法步骤 #
现在假设我们有$N$个样本需要预测类别,模型直接的预测结果是$N$个概率分布$p^{(1)},p^{(2)},\cdots,p^{(N)}$,假设测试样本和训练样本是同分布的,那么完美的预测结果应该有:
\begin{equation}\frac{1}{N}\sum_{i=1}^N p^{(i)} = \tilde{p}\label{eq:prior}\end{equation}
其中$\tilde{p}$是类别的先验分布,我们可以直接从训练集估计。也就是说,全体预测结果应该跟先验分布是一致的,但受限于模型性能等原因,实际的预测结果可能明显偏离上式,这时候我们就可以人为修正这部分。
具体来说,我们选定一个阈值$\tau$,将指标小于$\tau$的预测结果视为高置信度的,而大于等于$\tau$的则是低置信度的,不失一般性,我们假设前$n$个结果$p^{(1)},p^{(2)},\cdots,p^{(n)}$属于高置信度的,而剩下的$N-n$个属于低置信度的。我们认为高置信度部分是更加可靠的,所以它们不用修正,并且可以用它们来作为“标准参考系”来修正低置信度部分。
具体来说,对于$\forall j\in\{n+1,n+2,\cdots,N\}$,我们将$p^{(j)}$与高置信度的$p^{(1)},p^{(2)},\cdots,p^{(n)}$一起,执行一次“行间”标准化:
\begin{equation}p^{(k)} \leftarrow p^{(k)}\big/\bar{p}\times\tilde{p},\quad\bar{p}=\frac{1}{n+1}\left(p^{(j)} + \sum_{i=1}^n p^{(i)}\right)\label{eq:step-1}\end{equation}
这里的$k\in\{1,2,\cdots,n\}\cup\{j\}$,其中乘除法都是element-wise的。不难发现,这个标准化的目的是使得所有新的$p^{(k)}$的平均向量等于先验分布$\tilde{p}$,也就是促使式$\eqref{eq:prior}$的成立。然而,这样标准化之后,每个$p^{(k)}$就未必满足归一化了,所以我们还要执行一次“行内”标准化:
\begin{equation}p^{(k)} \leftarrow \frac{p^{(k)}_i}{\sum\limits_{i=1}^m p^{(k)}_i}\label{eq:step-2}\end{equation}
但这样一来,式$\eqref{eq:prior}$可能又不成立了。所以理论上我们可以交替迭代执行这两步,直到结果收敛(不过实验结果显示一般情况下一次的效果是最好的)。最后,我们只保留最新的$p^{(j)}$作为原来第$j$个样本的预测结果,其余的$p^{(k)}$均弃之不用。
注意,这个过程需要我们遍历每个低置信度结果$j\in\{n+1,n+2,\cdots,N\}$执行,也就是说是逐个样本进行修正,而不是一次性修正的,每个$p^{(j)}$都借助原始的高置信度结果$p^{(1)},p^{(2)},\cdots,p^{(n)}$组合来按照上述步骤迭代,虽然迭代过程中对应的$p^{(1)},p^{(2)},\cdots,p^{(n)}$都会随之更新,但那只是临时结果,最后都是弃之不用的,每次修正都是用原始的$p^{(1)},p^{(2)},\cdots,p^{(n)}$。
参考实现 #
这是笔者给出的参考实现代码:
# 预测结果,计算修正前准确率
y_pred = model.predict(
valid_generator.fortest(), steps=len(valid_generator), verbose=True
)
y_true = np.array([d[1] for d in valid_data])
acc_original = np.mean([y_pred.argmax(1) == y_true])
print('original acc: %s' % acc_original)
# 评价每个预测结果的不确定性
k = 3
y_pred_topk = np.sort(y_pred, axis=1)[:, -k:]
y_pred_topk /= y_pred_topk.sum(axis=1, keepdims=True)
y_pred_uncertainty = -(y_pred_topk * np.log(y_pred_topk)).sum(1) / np.log(k)
# 选择阈值,划分高、低置信度两部分
threshold = 0.9
y_pred_confident = y_pred[y_pred_uncertainty < threshold]
y_pred_unconfident = y_pred[y_pred_uncertainty >= threshold]
y_true_confident = y_true[y_pred_uncertainty < threshold]
y_true_unconfident = y_true[y_pred_uncertainty >= threshold]
# 显示两部分各自的准确率
# 一般而言,高置信度集准确率会远高于低置信度的
acc_confident = (y_pred_confident.argmax(1) == y_true_confident).mean()
acc_unconfident = (y_pred_unconfident.argmax(1) == y_true_unconfident).mean()
print('confident acc: %s' % acc_confident)
print('unconfident acc: %s' % acc_unconfident)
# 从训练集统计先验分布
prior = np.zeros(num_classes)
for d in train_data:
prior[d[1]] += 1.
prior /= prior.sum()
# 逐个修改低置信度样本,并重新评价准确率
right, alpha, iters = 0, 1, 1
for i, y in enumerate(y_pred_unconfident):
Y = np.concatenate([y_pred_confident, y[None]], axis=0)
for j in range(iters):
Y = Y**alpha
Y /= Y.mean(axis=0, keepdims=True)
Y *= prior[None]
Y /= Y.sum(axis=1, keepdims=True)
y = Y[-1]
if y.argmax() == y_true_unconfident[i]:
right += 1
# 输出修正后的准确率
acc_final = (acc_confident * len(y_pred_confident) + right) / len(y_pred)
print('new unconfident acc: %s' % (right / (i + 1.)))
print('final acc: %s' % acc_final)
实验结果 #
那么,这样的简单后处理,究竟能带来多大的提升呢?原论文给出的实验结果是相当可观的:
笔者也在CLUE上的两个中文文本分类任务上做了实验,显示基本也有点提升,但没那么可观(验证集结果):
\begin{array}{c|c|c}
\hline
& \text{IFLYTEK(类别数:119)} & \text{TNEWS(类别数:15)}\\
\hline
\text{BERT} & 60.06\% & 56.80\% \\
\text{BERT + CAN} & 60.52\% & 56.86\% \\
\hline
\text{RoBERTa} & 60.64\% & 58.06\% \\
\text{RoBERTa + CAN} & 60.95\% & 58.00\% \\
\hline
\end{array}
大体上来说,类别数目越多,效果提升越明显,如果类别数目比较少,那么可能提升比较微弱甚至会下降(当然就算下降也是微弱的),所以这算是一个“几乎免费的午餐”了。超参数选择方面,上面给出的中文结果,只迭代了1次,$k$的选择为3、$\tau$的选择为0.9,经过简单的调试,发现这基本上已经是比较优的参数组合了。
还有的读者可能想问前面说的“高置信度那部分结果更可靠”这个情况是否真的成立?至少在笔者的两个中文实验上它是明显成立的,比如IFLYTEK任务,筛选出来的高置信度集准确率为0.63+,而低置信度集的准确率只有0.22+;TNEWS任务类似,高置信度集准确率为0.58+,而低置信度集的准确率只有0.23+。
个人评价 #
最后再来综合地思考和评价一下CAN。
首先,一个很自然的疑问是为什么不直接将所有低置信度结果跟高置信度结果拼在一起进行修正,而是要逐个进行修正?笔者不知道原论文作者有没有对比过,但笔者确实实验过这个想法,结果是批量修正有时跟逐个修正持平,但有时也会下降。其实也可以理解,CAN本意应该是借助先验分布,结合高置信度结果来修正低置信度的,在这个过程中,如果掺入越多的低置信度结果,那么最终的偏差可能就越大,因此理论上逐个修正会比批量修正更为可靠。
说到原论文,读过CAN论文的读者,应该能发现本文介绍与CAN原论文大致有三点不同:
1、不确定性指标的计算方法不同。按照原论文的描述,它最终的不确定性指标计算方式应该是
\begin{equation}-\frac{1}{\log m}\sum_{i=1}^k p_i\log p_i\end{equation}
也就是说,它也是top-$k$个概率算熵的形式,但是它没有对这$k$个概率值重新归一化,并且它将其压缩到0~1之间的因子是$\log m$而不是$\log k$(因为它没有重新归一化,所以只有除$\log m$才能保证0~1之间)。经过笔者测试,原论文的这种方式计算出来的结果通常明显小于1,这不利于我们对阈值的感知和调试。
2、对CAN的介绍方式不同。原论文是纯粹数学化、矩阵化地陈述CAN的算法步骤,而且没有介绍算法的思想来源,这对理解CAN是相当不友好的。如果读者没有自行深入思考算法原理,是很难理解为什么这样的后处理手段就能提升分类效果的,而在彻底弄懂之后则会有一种故弄玄虚之感。
3、CAN的算法流程略有不同。原论文在迭代过程中还引入了参数$\alpha$,使得式$\eqref{eq:step-1}$变为
\begin{equation}p^{(k)} \leftarrow [p^{(k)}]^{\alpha}\big/\bar{p}\times\tilde{p},\quad\bar{p}=\frac{1}{n+1}\left([p^{(j)}]^{\alpha} + \sum_{i=1}^n [p^{(i)}]^{\alpha}\right)\end{equation}
也就是对每个结果进行$\alpha$次方后再迭代。当然,原论文也没有对此进行解释,而在笔者看来,该参数纯粹是为了调参而引入的(参数多了,总能把效果调到有所提升),没有太多实际意义。而且笔者自己在实验中发现,$\alpha=1$基本已经是最优选择了,精调$\alpha$也很难获得是实质收益。
文章小结 #
本文介绍了一种名为CAN的简单后处理技巧,它借助先验分布来将预测结果重新归一化,几乎没有增加多少计算成本就能提高分类性能。经过笔者的实验,CAN确实能给分类效果带来一定提升,并且通常来说类别数越多,效果越明显。
转载到请包括本文地址:https://kexue.fm/archives/8728
更详细的转载事宜请参考:《科学空间FAQ》
如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。
如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!
如果您需要引用本文,请参考:
苏剑林. (Oct. 22, 2021). 《CAN:借助先验分布提升分类性能的简单后处理技巧 》[Blog post]. Retrieved from https://kexue.fm/archives/8728
@online{kexuefm-8728,
title={CAN:借助先验分布提升分类性能的简单后处理技巧},
author={苏剑林},
year={2021},
month={Oct},
url={\url{https://kexue.fm/archives/8728}},
}
November 15th, 2021
论文里面行间正则化好像是概率分布直接除以sum(p,0)也就是概率分布行之间的和,这个在置信度高的样本数量固定时和均值看起来差别只有一个系数$\frac{1}{n+1}$,所以均值的话是存在什么方面的考虑呢?
由于还要进行“行内”标准化,所以不管是求和还是平均,结果是等价的。之所以用平均,是因为按照本文的推导思路,它确实是平均。而我认为自己的推导更直观易懂。
November 19th, 2021
训练集做过类别平衡(上下采样),测试集没有平衡的话是不是就不适用
训练集只是用来估计先验分布,如果你觉得做了平衡后的先验分布不准,那就不做平衡再估计就行了。至于训练过程有没有平衡,这是不重要的。
December 4th, 2021
谢谢博主!我刚刚看完论文,附录里面提了Multilabel 问题,一个样本如果可以有多个标签,文中说将n*m的输出,转位nm*2的输出,我理解2应该就是是否为某一类的概率,但我有些不太清楚后续如果应用这个算法应该怎么考虑归一化,和类别先验呢,不知道楼主是不是清楚~ 十分感谢
Multilabel就是当作多个二分类来处理而已,每个二分类按照正常的CAN算法来处理。
December 13th, 2021
作者你好,我尝试运博客的代码,把 y_pred 初始化成
y_pred = np.array([
[0, 1, 0, 0, 0],
[0, 0, 1, 0, 0],
[1, 0, 0, 0, 0],
[0, 0, 1, 0, 0],
[1, 0, 0, 0, 0]
]),运行到y_pred_topk /= y_pred_topk.sum(axis=1, keepdims=True)时出现这个报错No loop matching the specified signature and casting was found for ufunc true_divide,请问执行了第一句 model.predict(...) 后,y_pred变量的数据类型是什么呢?实属小白,望解惑
我懂了,model.predict()结果并不是one-hot码,而是一串分类的概率值
我代进去算,没发现这个错误啊。
确实没问题,感谢回答。不过我尝试用在9分类数据集上,发现准确率下降了,验证了对分类类别数量少的任务不友好这个推断
嗯嗯,谢谢反馈
9分类任务还算类别少?
9分类确实不算多,不然90、900分类算啥
February 28th, 2022
博主你好,我想请问一下~
博客以及原论文中的方法,都是基于测试集完整可见的场景下进行的针对模型预测的后处理。
假设测试集是流式的,或者如具体业务场景下,模型在线上runtime中,只能一个一个样本进行预测下,这个方法有什么好的适配思路么?
请认真理解一下,CAN就是一个个样本进行预测的,不是完整测试集进行预测的。
它是需要有一个验证集的预测结果来辅助预测,这个预测结果可以确定下来,相当于模型参数,然后每次还是一个个样本预测。
March 9th, 2022
为什么我在我的5分类类别上进行实验,发现准确率原封不动的,没有任何改变,是本身CAN对我以及预测的了无法优化了吗?
确实无法保证更好或者更差的,如果你的预测结果已经很符合先验分布了,那就不会有什么校正了。
March 11th, 2022
我知道原因了,因为我在划分训练集,验证集和测试集是按标签均匀划分,为了加入can,我有必要不做均匀划分?如果不做均匀划分,是否又会不合理了?
这个倒没多大关系。CAN要做的事情是:本来验证集的预测结果的分布应该跟先验分布一致,但事实上不一致,但是CAN能修正一些;但是如果事实上也一致,那么CAN也修正不了什么。
March 11th, 2022
请问传进来的 valid_data值和train_data是什么格式的数据啊
是链表 还是数组 还是张量呢 恳请大佬指教
理解代码后自行适配自己的格式,不需要套用代码。
October 26th, 2022
这个有什么优化空间吗?整体看整个计算逻辑有点复杂。每个低置信样本的修正需要用到所有的 y_pred_confident。对于大体量的数据可用性较弱呀。