本文将介绍一种用于分类问题的后处理技巧(Trick),出自EMNLP 2021 Findings的一篇论文《When in Doubt: Improving Classification Performance with Alternating Normalization》。经过实测,CAN(Classification with Alternating Normalization)确实多数情况下能提升多分类问题的效果(CV、NLP通用),而且几乎没有增加预测成本,因为它仅仅只是对预测结果的重新归一化操作
有趣的是,其实CAN的思想非常朴素,朴素到我们每个人几乎都用过。具体来说,假设考试中有10道选择题,前9道你都比较有信心,第10题完全不会只能瞎蒙,但是你发现前面9题选A、B、C、D的比例是3:3:2:2,那么第10题在蒙的时候,你会不会更倾向于C和D?如果是更极端的情况,你发现前面9题选A、B、C的都有,但就是没有选D的,那你蒙第10题的时候,会不会更倾向于D?
回到分类任务上,假设现在有一个二分类问题,模型对于输入 a a a给出的预测结果是 p ( a ) = [ 0.05 , 0.95 ] p^{(a)}=[0.05, 0.95] p(a)=[0.05,0.95],那么我们就可以给出预测类别为1;接下来,对于输入 b b b,模型给出的预测结果是 p ( b ) = [ 0.5 , 0.5 ] p^{(b)}=[0.5,0.5] p(b)=[0.5,0.5],这种结果是最不确定的,我们也不知道应该输出哪个类别
但是,假如我告诉你:
在已知这两点「先验」信息的情况下,由于前一个样本的预测结果为1,那么基于朴素的均匀思想,我们是否会更倾向于将后一个样本预测为0,以得到一个满足第二点「先验」的预测结果?
这些简单的例子背后,有着跟CAN同样的思想,其实就是用「先验分布」来校正「低置信度」的预测结果,使得新的预测结果的分布更接近先验分布
准确地说,CAN是针对低置信度预测结果的后处理手段,所以我们首先要有一个衡量预测结果不确定性的指标。常见的度量是「熵」,对于
p
=
[
p
1
,
p
2
,
.
.
.
,
p
m
]
p=[p_1,p_2,...,p_m]
p=[p1,p2,...,pm],定义为
H
(
p
)
=
−
∑
i
=
1
m
p
i
log
p
i
(1)
H(p) = -\sum_{i=1}^m p_i \log p_i\tag {1}
H(p)=−i=1∑mpilogpi(1)
虽然熵是一个常见的选择,但其实它得出的结果并不总是符合我们的直观理解。例如对于
p
(
a
)
=
[
0.5
,
0.25
,
0.25
]
p^{(a)}=[0.5, 0.25,0.25]
p(a)=[0.5,0.25,0.25]和
p
(
b
)
=
[
0.5
,
0.5
,
0
]
p^{(b)}=[0.5,0.5,0]
p(b)=[0.5,0.5,0],直接套用公式得到
H
(
p
(
a
)
)
>
H
(
p
(
b
)
)
H(p^{(a)})>H(p^{(b)})
H(p(a))>H(p(b)),但如果让人主观去评价这两个概率分布,显然我们会认为
p
(
b
)
p^{(b)}
p(b)比
p
(
a
)
p^{(a)}
p(a)更不确定,所以直接用熵还不够合理
客观地讲,熵值越大,表示这个系统内部越不稳定。如果要与置信度联系起来,熵值越大,置信度越低
一个简单的修正是只用前top-k个概率值来算熵,假设
p
1
,
p
2
,
.
.
.
,
p
k
p_1,p_2,...,p_k
p1,p2,...,pk是概率最高的
k
k
k个值,那么
H
top-k
(
p
)
=
H
(
T
(
p
,
k
)
)
(2)
H_{\text{top-k}}(p) = H(\mathcal{T}(p, k))\tag{2}
Htop-k(p)=H(T(p,k))(2)
其中,
T
\mathcal{T}
T是一个取向量最大的前k个值的操作
R
m
→
R
k
\mathbb{R}^{m}\to \mathbb{R}^{k}
Rm→Rk。我们可以将式(2)带入式(1)展开得
H
top-k
(
p
)
=
−
∑
i
=
1
k
p
~
i
log
p
~
i
(3)
H_{\text{top-k}}(p) = -\sum_{i=1}^k \tilde{p}_i \log \tilde{p}_i \tag{3}
Htop-k(p)=−i=1∑kp~ilogp~i(3)
其中,
p
~
i
=
p
i
/
∑
i
=
1
k
p
i
\tilde{p}_i = p_i / \sum\limits_{i=1}^k p_i
p~i=pi/i=1∑kpi
这一部分我先给出论文中的算法步骤描述,下一节我会手动模拟一遍计算过程
设列向量
b
0
∈
R
m
\mathbf{b}_0\in \mathbb{R}^m
b0∈Rm为输入样本
x
x
x对应各类别的概率分布,
m
m
m表示类别数。我们生成一个
n
×
m
n\times m
n×m的概率矩阵
A
0
A_0
A0,
A
0
A_0
A0其实是
n
n
n个置信度非常高的样本对各个类别的预测概率向量拼接而得,通过将
A
0
A_0
A0和
b
0
\mathbf{b}_0
b0进行拼接得到一个
(
n
+
1
)
×
m
(n+1)\times m
(n+1)×m的矩阵
L
0
L_0
L0
L
0
=
[
A
0
b
0
T
]
L_{0}=\left[A0bT0\right]
L0=[A0b0T]
第二步是一个迭代的过程,具体来说,首先对矩阵
L
0
L_0
L0进行列归一化(使得每列求和为1),然后进行行归一化(使得每行求和为1)。进行算法步骤前,先定义一个向量对角化操作:
D
:
R
n
→
R
n
×
n
\mathcal{D}:\mathbb{R}^n \to \mathbb{R}^{n\times n}
D:Rn→Rn×n
D
(
v
)
\mathcal{D}(\mathbf{v})
D(v)会将列向量
v
∈
R
n
\mathbf{v}\in \mathbb{R}^n
v∈Rn转换为
n
×
n
n\times n
n×n的对角矩阵,对角线元素即原本的向量元素
Λ S = D ( ( L d − 1 α ) T e ) S d = L d − 1 α Λ S − 1 (4) ΛS=D((Lαd−1)Te)Sd=Lαd−1Λ−1S \tag{4} ΛSSd=D((Ld−1α)Te)=Ld−1αΛS−1(4)
其中,参数 α ∈ N + \alpha \in \mathbb{N}^+ α∈N+控制着 b 0 \mathbf{b}_0 b0收敛到高置信度的速度(越大速度越快,默认取1); e ∈ R n + 1 \mathbf{e}\in \mathbb{R}^{n+1} e∈Rn+1是全1的列向量。经过式(4)的变换后,矩阵 S d ∈ R ( n + 1 ) × m S_d\in \mathbb{R}^{(n+1)\times m} Sd∈R(n+1)×m是 L d − 1 L_{d-1} Ld−1矩阵的列归一化形式; Λ S − 1 \Lambda_S^{-1} ΛS−1是 Λ S \Lambda_S ΛS的逆矩阵
Λ L = D ( S d Λ q e ) L d = Λ L − 1 S d Λ q (5) ΛL=D(SdΛqe)Ld=Λ−1LSdΛq\tag{5} ΛLLd=D(SdΛqe)=ΛL−1SdΛq(5)
其中, e ∈ R m \mathbf{e}\in \mathbb{R}^{m} e∈Rm仍然是全1的列向量,只不过此时它的维度是 m m m维的;矩阵 L d ∈ R ( n + 1 ) × m L_d\in \mathbb{R}^{(n+1)\times m} Ld∈R(n+1)×m是行归一化的(但 L d L_d Ld并不是具体某个矩阵的行归一化形式); Λ q ∈ R m × m \Lambda_q \in \mathbb{R}^{m\times m} Λq∈Rm×m是一个对角矩阵,对角线上的元素是各类别的分布占比
例如
Λ q = [ 0.2 0 0 0 0.4 0 0 0 0.4 ] \Lambda_q = [0.20000.40000.4] Λq=⎣⎡0.20000.40000.4⎦⎤
表示这是一个三分类问题,并且各个类别的比例为1:2:2
Step2循环迭代
d
d
d次后得到的矩阵
L
d
L_d
Ld:
L
d
=
[
A
d
b
d
T
]
L_{d}=\left[AdbTd\right]
Ld=[AdbdT]
其中,
b
d
\mathbf{b}_d
bd就是根据「先验分布」调整后的新的概率分布
注意,这个过程需要我们遍历每个低置信度的预测结果,也就是说逐个样本进行修正,而不是一次性修正的。并且虽然迭代过程中 A 0 A_0 A0里对应的各个样本的预测概率也都随之更新了,但那只是临时结果,最后都是弃之不用的,每次修正都是用原始的 A 0 A_0 A0
首先我们设置一些矩阵和参数
A
0
=
[
0.2
0
0.8
0.9
0.1
0
0
0
1
]
b
0
=
[
0.5
0
0.5
]
Λ
q
=
[
0.8
0.1
0.1
]
α
=
1
,
d
=
1
A0=[0.200.80.90.10001]b0=[0.500.5]Λq=[0.80.10.1]α=1,d=1
A0b0Λqα=⎣⎡0.20.9000.100.801⎦⎤=⎣⎡0.500.5⎦⎤=⎣⎡0.80.10.1⎦⎤=1,d=1
稍微解释一下,
A
0
A_0
A0根据原算法描述是
n
n
n个置信度比较高的样本的预测概率分布进行拼接,可以看出只有3个样本置信度比较高,并且他们的预测类别分别为2,0,2;
b
0
b_0
b0是某样本
x
x
x的预测概率,因为是概率分布,所以必须满足求和为1;
Λ
q
\Lambda_q
Λq是三个类别的样本比例,可以看出第一个类别的数据非常多
首先是列归一化
Λ
S
=
D
(
L
0
T
e
)
=
D
(
[
0.2
0
0.8
0.9
0.1
0
0
0
1
0.5
0
0.5
]
T
[
1
1
1
1
]
)
=
D
(
[
1.6
0.1
2.3
]
)
=
[
1.6
0.1
2.3
]
S
d
=
L
0
Λ
S
−
1
=
[
0.2
0
0.8
0.9
0.1
0
0
0
1
0.5
0
0.5
]
[
1
/
1.6
10
1
/
2.3
]
=
[
1
/
8
0
8
/
23
9
/
16
1
0
0
0
10
/
23
5
/
16
0
5
/
23
]
ΛS=D(LT0e)=D([0.200.80.90.100010.500.5]T[1111])=D([1.60.12.3])=[1.60.12.3]Sd=L0Λ−1S=[0.200.80.90.100010.500.5][1/1.6101/2.3]=[1/808/239/16100010/235/1605/23]
ΛSSd=D(L0Te)=D(⎣⎢⎢⎡0.20.900.500.1000.8010.5⎦⎥⎥⎤T⎣⎢⎢⎡1111⎦⎥⎥⎤)=D(⎣⎡1.60.12.3⎦⎤)=⎣⎡1.60.12.3⎦⎤=L0ΛS−1=⎣⎢⎢⎡0.20.900.500.1000.8010.5⎦⎥⎥⎤⎣⎡1/1.6101/2.3⎦⎤=⎣⎢⎢⎡1/89/1605/1601008/23010/235/23⎦⎥⎥⎤
仔细观察矩阵
S
d
S_d
Sd,它每列求和都是1,也就是列归一化,如果我们追根溯源的话,实际上
S
d
S_d
Sd就是
L
0
L_0
L0对每列求和,然后将
L
0
L_0
L0每列元素除以该和
接着是行归一化
Λ
L
=
D
(
[
1
/
8
0
8
/
23
9
/
16
1
0
0
0
10
/
23
5
/
16
0
5
/
23
]
[
0.8
0.1
0.1
]
[
1
1
1
]
)
=
D
(
[
31
/
230
11
/
20
1
/
23
25
/
92
]
)
=
[
31
/
230
11
/
20
1
/
23
25
/
92
]
L
1
=
[
230
/
31
20
/
11
23
92
/
25
]
[
1
/
8
0
8
/
23
9
/
16
1
0
0
0
10
/
23
5
/
16
0
5
/
23
]
[
0.8
0.1
0.1
]
=
[
23
/
31
0
8
/
31
9
/
11
2
/
11
0
0
0
1
23
/
25
0
2
/
25
]
ΛL=D([1/808/239/16100010/235/1605/23][0.80.10.1][111])=D([31/23011/201/2325/92])=[31/23011/201/2325/92]L1=[230/3120/112392/25][1/808/239/16100010/235/1605/23][0.80.10.1]=[23/3108/319/112/11000123/2502/25]
ΛLL1=D(⎣⎢⎢⎡1/89/1605/1601008/23010/235/23⎦⎥⎥⎤⎣⎡0.80.10.1⎦⎤⎣⎡111⎦⎤)=D(⎣⎢⎢⎡31/23011/201/2325/92⎦⎥⎥⎤)=⎣⎢⎢⎡31/23011/201/2325/92⎦⎥⎥⎤=⎣⎢⎢⎡230/3120/112392/25⎦⎥⎥⎤⎣⎢⎢⎡1/89/1605/1601008/23010/235/23⎦⎥⎥⎤⎣⎡0.80.10.1⎦⎤=⎣⎢⎢⎡23/319/11023/2502/11008/31012/25⎦⎥⎥⎤
我们只需要
L
1
L_1
L1的最后一行,即
b
1
=
[
23
/
25
0
2
/
25
]
T
\mathbf{b}_1=[23/2502/25]^T
b1=[23/2502/25]T,可以看,原本
b
0
\mathbf{b}_0
b0的概率分布是
[
0.5
0
0.5
]
T
[0.500.5]^T
[0.500.5]T,经过「先验」调整后的类别明显偏向数据占比比较多的第一类,并且
b
1
\mathbf{b}_1
b1向量求和为1,符合概率的定义
实际上这个过程用Python实现也非常简单,下面就是我自己写的一个代码,变量命名与公式中的变量名完全一致
import numpy as np
n, m, d, alpha = 3, 3, 5, 1
# n: 样本数量
# m: 类别数
# d: 迭代次数
# alpha: 次方
def softmax(arr):
return np.exp(arr) / np.sum(np.exp(arr))
A_0 = np.array([[0.2, 0, 0.8], [0.9, 0.1, 0], [0, 0, 1]])
# A_0 = softmax(np.random.randn(n, m))
b_0 = np.array([0.5, 0, 0.5])
# b_0 = softmax(np.random.randn(m))
L_0 = np.vstack((A_0, b_0)) # (n+1) * m
Lambda_q = np.diag( np.array([0.8, 0.1, 0.1]) )
# Lambda_q = np.diag( softmax(np.random.randn(m)) )
print("预测概率:", b_0)
print("各类别样本数量分布:", np.diag(Lambda_q, 0))
L_d_1 = L_0
for _ in range(d):
Lambda_S = np.diag( np.dot((L_d_1 ** alpha).T, np.ones((n + 1))) )
S_d = np.dot((L_d_1 ** alpha), np.linalg.inv(Lambda_S))
Lambda_L = np.diag( np.dot(np.dot(S_d, Lambda_q), np.ones((m))) )
L_d_1 = np.dot( np.dot(np.linalg.inv(Lambda_L), S_d), Lambda_q )
print("根据先验调整后的概率:", L_d_1[-1:])
下面给出苏剑林大佬的实现,他的代码中将Top-k熵做了个归一化,保证
H
top-k
(
p
)
∈
[
0
,
1
]
H_{\text{top-k}}(p)\in [0,1]
Htop-k(p)∈[0,1],这样比较好确定阈值(即代码中的threshold)
import numpy as np
# 预测结果,计算修正前准确率
y_pred = np.array([[0.2, 0.5, 0.2, 0.1],
[0.3, 0.1, 0.5, 0.1],
[0.4, 0.1, 0.1, 0.4],
[0.1, 0.1, 0.1, 0.8],
[0.3, 0.2, 0.2, 0.3],
[0.2, 0.2, 0.2, 0.4]])
num_classes = y_pred.shape[1]
y_true = np.array([0, 1, 2, 3, 1, 2])
acc_original = np.mean([y_pred.argmax(1) == y_true])
print('original acc: %s' % acc_original)
# 从训练集统计先验分布
# prior = np.zeros(num_classes)
# for d in train_data:
# prior[d[1]] += 1.
# prior /= prior.sum()
prior = np.array([0.2, 0.2, 0.25, 0.35])
# 评价每个预测结果的不确定性
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_entropy = -(y_pred_topk * np.log(y_pred_topk)).sum(1) / np.log(k) # top-k熵
print(y_pred_entropy)
# 选择阈值,划分高、低置信度两部分
threshold = 0.9
y_pred_confident = y_pred[y_pred_entropy < threshold] # top-k熵低于阈值的是高置信度样本
y_pred_unconfident = y_pred[y_pred_entropy >= threshold] # top-k熵高于阈值的是低置信度样本
y_true_confident = y_true[y_pred_entropy < threshold]
y_true_unconfident = y_true[y_pred_entropy >= 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)
# 逐个修改低置信度样本,并重新评价准确率
right, alpha, iters = 0, 1, 1 # 正确的个数,alpha次方,iters迭代次数
for i, y in enumerate(y_pred_unconfident):
Y = np.concatenate([y_pred_confident, y[None]], axis=0) # Y is L_0
for _ in range(iters):
Y = Y ** alpha
Y /= Y.sum(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)
那么,这样简单的后处理,究竟能带来多大的提升呢?原论文给出的实验结果是相当可观的:

大体来说,类别数越多,效果提升越明显,如果类别数比较少,那么提升可能比较微弱甚至会下降
一个很自然的疑问是为什么不直接将所有低置信度的结果跟高置信度的结果拼在一起进行修正,而是要逐个修正?其实很好理解,CAN本意是要借助「先验分布」,结合高置信度结果来修正低置信度,在这个过程中如果掺入的低置信度结果越多,最终的偏差可能就越大,因此理论上逐个修正会比批量修正更为可靠