本来的雄心壮志是大致看完计算机视觉 - 对比学习的13篇文章(精读+略读),结果看了前两篇发现真的很难理解。最后,调整下目标:先以InstDisc和CPC两篇文章作为引入,搞清楚NCE loss和InfoNCE loss的原理及代码,最后达到可以在自己的任务中设计这两种损失的程度…
前置知识
有一些前置概念先做一些简单的科普:
- odds、logit:概率 P ( A ) P(A) P(A)是指事件A发生的概率,odds指的是几率,表示事件A发生的概率和事件A不发生的概率之比,即 P ( A ) / 1 − P ( A ) P(A)/1-P(A) P(A)/1−P(A),对数几率是对几率取对数,即 l o g P ( A ) 1 − P ( A ) log{\frac{P(A)}{1-P(A)}} log1−P(A)P(A),logit就是将概率 P ( A ) P(A) P(A)转化为 l o g P ( A ) 1 − P ( A ) log{\frac{P(A)}{1-P(A)}} log1−P(A)P(A)的这个变换 l o g i t ( P ) = l o g ( p 1 − p ) logit(P) = log(\frac{p}{1-p}) logit(P)=log(1−pp)。具体可参考FAQ: HOW DO I INTERPRET ODDS RATIOS IN LOGISTIC REGRESSION?
- Log-bilinear model:对数双线性模型,有一篇博客讲得非常好:Log-bilinear model
- RNN语言模型:生成自然语言句子或文档的模型,用于评估一个句子或模型有多自然。具体参考RNN Language Models
- 无监督学习和自监督学习:无监督学习是说使用没有标签的数据进行学习的过程,自监督学习是利用数据自主生成标签的学习过程,一般认为自监督学习是无监督学习的一种
- Autoregressive model:自回归模型,回归模型是说对一些自变量 x i x_i xi进行因变量 y y y的预测,而在自回归中, x i x_i xi是 y y y的过去值。参考:Autoregressive models
- 交叉熵损失和二元交叉熵损失:两者的区别在于,二元交叉熵损失在标签 y i y_i yi为 0 0 0的时候,也会计算入损失。具体参考:交叉熵损失和二元交叉熵损失
InstDisc
2018-CVPR-Unsupervised Feature Learning via Non-Parametric Instance Discrimination
任务:无监督特征学习
代理任务:个体判别
基本思想:将每个个体都当作是一个独立的类,来学习每个类的特征表示
主要创新:类如果太多的话,常规的softmax方法会因为计算爆炸而无法处理那么多类,于是使用了NCE方法来将多分类问题转化为二分类问题
其他创新:1)将原本有参数的softmax分类器替换为存储个体特征的无参数内存银行;2)测试阶段使用加权的knn算法来对目标样本进行个体判别
备注:在距离计算中使用了温度超参数
τ
\tau
τ来控制特征在映射空间中的分散程度
NCE loss原理分析:
本来是参考求通俗易懂解释下nce loss? - 石塔西的回答 - 知乎和candidate_sampling这两篇文章学习NCE的,因为前者比较系统,后者是官方文件,但是感觉这两篇文章理解起来有些困难,所以可以选择性地浏览下
J
N
C
E
(
θ
)
=
−
E
P
d
[
l
o
g
h
(
i
,
v
)
]
−
m
E
P
n
[
l
o
g
(
1
−
h
(
i
,
v
′
)
)
]
J_{NCE}(\theta) = -E_{P_d}[log\ h(i, v)] - m E_{P_n}[log(1-h(i,v^{'}))]
JNCE(θ)=−EPd[log h(i,v)]−mEPn[log(1−h(i,v′))],
P
n
P_n
Pn包括两部分:target在噪声分布中对应的概率,记为
P
n
:
t
a
r
g
e
t
P_{n:target}
Pn:target;及noise在噪声分布中对应的概率,记为
P
n
:
n
o
i
s
e
P_{n:noise}
Pn:noise
其中,
h
(
i
,
v
)
:
=
P
(
D
=
1
∣
i
,
v
)
=
P
(
i
∣
v
)
P
(
i
∣
v
)
+
m
P
n
:
t
a
r
g
e
t
(
i
)
h(i,v) := P(D=1|i,v) = \frac{P(i|v)}{P(i|v)+mP_{n:target}(i)}
h(i,v):=P(D=1∣i,v)=P(i∣v)+mPn:target(i)P(i∣v)
而
P
(
i
∣
v
)
=
e
x
p
(
v
T
f
i
/
τ
)
∑
j
=
1
n
e
x
p
(
v
j
T
f
i
/
τ
)
P(i|v) = \frac{exp(v^Tf_i/\tau)}{\sum_{j=1}^nexp(v_j^Tf_i/\tau)}
P(i∣v)=∑j=1nexp(vjTfi/τ)exp(vTfi/τ)
相应地,
h
(
i
,
v
′
)
:
=
P
(
D
=
0
∣
i
,
v
)
=
P
(
i
∣
v
′
)
P
(
i
∣
v
′
)
+
m
P
n
:
n
o
i
s
e
(
i
)
h(i,v^{'}) := P(D=0|i,v) = \frac{P(i|v^{'})}{P(i|v^{'})+mP_{n:noise}(i)}
h(i,v′):=P(D=0∣i,v)=P(i∣v′)+mPn:noise(i)P(i∣v′)
P
(
i
∣
v
′
)
=
e
x
p
(
v
′
T
f
i
/
τ
)
∑
j
=
1
n
e
x
p
(
v
j
T
f
i
/
τ
)
P(i|v^{'}) = \frac{exp(v^{{'}T}f_i/\tau)}{\sum_{j=1}^nexp(v_j^Tf_i/\tau)}
P(i∣v′)=∑j=1nexp(vjTfi/τ)exp(v′Tfi/τ)
总结下,NCE损失是一个二元交叉熵损失的形式,其中第一项是对正例进行的计算,
P
d
P_d
Pd是指真实分布;第二项是对负例进行的计算,
P
n
P_n
Pn是指噪声分布;
v
v
v表示target计算出的特征,
v
′
v^{'}
v′表示noise计算出的特征
NCE loss代码分析:
这里找了一个github上面比较popular的RNNLM(RNN语言模型)的代码来分析(主要是因为轻松就能运行,不用复杂地下数据集配环境啥的),接下来以这个代码为例,结合上面的公式进行分析:
p_true = logit_model.exp() / (logit_model.exp() + self.noise_ratio * logit_noise.exp())
这一句是在计算
J
N
C
E
J_{NCE}
JNCE中的
h
(
i
,
v
)
h(i,v)
h(i,v)和
h
(
i
,
v
′
)
h(i,v^{'})
h(i,v′),其中self.noise_ratio
是指负样本数量是正样本数量的多少倍,即公式中的
m
m
m,代码中是
50
50
50;具体来说,p_true
其实就是
[
h
(
i
,
v
)
,
h
(
i
,
v
′
)
]
[h(i,v), h(i,v^{'})]
[h(i,v),h(i,v′)](
[
∗
,
∗
∗
]
[*, **]
[∗,∗∗]表示对
∗
*
∗和
∗
∗
**
∗∗进行concat),维度是bsz, max_len, num_target+noise_ratio=1+m=51
;有了p_true
之后,再准备好label
就可以进行二元交叉熵损失的计算了:
label = torch.zeros_like(logit_model)
label[:, :, 0] = 1
loss = self.bce_with_logits(p_true, label).sum(dim=2)
而logit_model
和logit_noise
及相应的变量(dim=2的维度上是51的变量)也都需要拆开为两部分来看,其中[:, :, 0]
代表真实数据部分,而[:, :, 1:]
代表噪声部分;下面分开介绍logit_model
和logit_noise
:
logit_model.exp()
表示的是 [ P ( i ∣ v ) , P ( i ∣ v ′ ) ] [P(i|v), P(i|v^{'})] [P(i∣v),P(i∣v′)](logit_model
是此概率的对数,维度是bsz, max_len, num_target+noise_ratio=51
),其计算方式为:
logit_model = torch.cat([logit_target_in_model.unsqueeze(2), logit_noise_in_model], dim=2)
logit_target_in_model
计算的是
P
(
i
∣
v
)
P(i|v)
P(i∣v),表示模型计算出的正样本(target)单词的logit,维度是bsz, max_len, 1
,其实是当前样本与正样本放入模型后的输出之间的内积,但是这个内积并没有经过温度超参数
τ
\tau
τ的调整和softmax归一化(原InstDisc公式中之所以用softmax归一化,是因为个体判别任务中类别概念的存在);
而logit_noise_in_model
则表示模型计算出的负样本(noise)单词的logit,计算的是
P
(
i
∣
v
′
)
P(i|v^{'})
P(i∣v′);维度是bsz, max_len, noise_ratio
,其实是当前样本与负样本放入模型后的输出之间的内积;
先看logit_target_in_model
,想要计算正样本的logit,只需要找到正样本的单词对应词汇表的index,再放入模型就可以了;正样本直接从数据集中拿
而logit_noise_in_model
,则需要先构造负样本,构造过程需要确定三个东西:1)负样本符合的分布;2)负样本的数量;3)生成负样本的方法;代码中给出的解决方案是:
1)使用数据集中原本的单词分布来作为负样本的分布:
def build_unigram_noise(freq):
total = freq.sum() # freq表示数据集中不同单词出现的频率,这个用很多现成包可以直接计算
noise = freq / total
assert abs(noise.sum() - 1) < 0.001
return noise
2)负样本的数量即noise_ratio
,这里是
50
50
50
3)生成负样本的方法使用别名方法,听说这种方法更快,别名方法类存储在/nce/alias_multinomial.py
中,其中__init__()
方法用于初始化,draw()
方法用于按照分布抽取某个数量的采样
logit_noise.exp()
表示的是噪声分布,logit_noise
的计算方式是:
logit_noise = torch.cat([logit_target_in_noise.unsqueeze(2), logit_noise_in_noise], dim=2)
其中,logit_target_in_noise
表示的是噪声分布给出的正样本的logit,即
P
n
:
t
a
r
g
e
t
P_{n:target}
Pn:target;logit_noise_in_noise
表示的是噪声分布给出的负样本的logit,即
P
n
:
n
o
i
s
e
P_{n:noise}
Pn:noise。这两者的计算方式是:
probs = noise / noise.sum() # noise已经介绍过了,是所有的单词在词汇表中出现的频率
probs = probs.clamp(min=BACKOFF_PROB)
renormed_probs = probs / probs.sum()
self.register_buffer('logprob_noise', renormed_probs.log())
logit_noise_in_noise = self.logprob_noise[noise_samples.data.view(-1)].view_as(noise_samples)
logit_target_in_noise = self.logprob_noise[target.data.view(-1)].view_as(target)
也就是说,直接在归一化的负样本分布(也是所有单词的分布)中找到target_word(正样本对应的单词)或noise_word(负样本选取的单词)对应的频率;
总结:
总算是把公式和代码对应起来了,两者如此难理解的原因是,代码中把真实数据分布和噪声部分concat在一起做了计算,这个蜜汁操作一度让我百思不得其解…现在看来,代码和公式中唯一不同的地方就在于
P
P
P的计算是否经过温度超参数
τ
\tau
τ的调整和softmax归一化了
CPC
2018-arXiv-Representation Learning with Contrastive Predictive Coding
任务:无监督特征学习
代理任务:预测编码(本来是神经科学中的名词,这里解释一下,即利用前t个时间步的输入
{
x
1
,
x
2
,
.
.
.
,
x
t
}
\{x_1, x_2, ..., x_{t}\}
{x1,x2,...,xt}学习时间步t的表示
c
t
c_t
ct,使这个表示能够更好地预测未来时间步的表示
{
x
t
+
1
,
x
t
+
2
,
.
.
.
,
x
t
+
k
}
\{x_{t+1}, x_{t+2}, ..., x_{t+k}\}
{xt+1,xt+2,...,xt+k})
动机:希望能够通过学习不同时间步信号的底层共享信息,来学习到一个好的表示
网络流程:将输入
{
x
1
,
x
2
,
.
.
.
,
x
t
}
\{x_1, x_2, ..., x_{t}\}
{x1,x2,...,xt}首先放入编码器
g
e
n
c
g_{enc}
genc得到
{
z
1
,
z
2
,
.
.
.
,
z
t
}
\{z_1, z_2, ..., z_{t}\}
{z1,z2,...,zt},然后放入自回归模型
g
a
r
g_{ar}
gar得到目标表示
c
t
c_t
ct,通过
c
t
c_t
ct来预测未来的表示
{
z
t
+
1
^
,
z
t
+
2
^
,
.
.
.
,
z
t
+
k
^
}
\{\hat{z_{t+1}}, \hat{z_{t+2}}, ..., \hat{z_{t+k}}\}
{zt+1^,zt+2^,...,zt+k^},将此预测与真实的通过
{
x
t
+
1
,
x
t
+
2
,
.
.
.
,
x
t
+
k
}
\{x_{t+1}, x_{t+2}, ..., x_{t+k}\}
{xt+1,xt+2,...,xt+k}放入
g
e
n
c
g_{enc}
genc得到的
{
z
t
+
1
,
z
t
+
2
,
.
.
.
,
z
t
+
k
}
\{z_{t+1}, z_{t+2}, ..., z_{t+k}\}
{zt+1,zt+2,...,zt+k}比较,准确的说是构建对比损失InfoNCE,从而更好的学习目标表示
c
t
c_t
ct
吐槽:这篇文章写得很晦涩,多亏理解Contrastive Predictive Coding和NCE Loss这篇文章才理解
InfoNCE loss原理分析:
L
N
=
−
E
X
[
l
o
g
f
k
(
x
t
+
k
,
c
t
)
∑
x
j
∈
X
f
k
(
x
j
,
c
t
]
L_N = -E_X[log\frac{f_k(x_{t+k}, c_t)}{\sum_{x_j\in X}f_k(x_j, c_t}]
LN=−EX[log∑xj∈Xfk(xj,ctfk(xt+k,ct)]
其中,
f
k
(
x
t
+
k
,
c
t
)
=
e
x
p
(
z
t
+
k
T
z
t
+
k
^
)
f_k(x_{t+k},c_t) = exp(z_{t+k}^T\hat{z_{t+k}})
fk(xt+k,ct)=exp(zt+kTzt+k^)
其中,
z
t
+
k
^
=
W
k
c
t
\hat{z_{t+k}} = W_k c_t
zt+k^=Wkct
损失函数是一个softmax交叉熵损失的形式,当分子越大,使得整个分式更接近1的时候(不会大于1),损失函数最小,因此要使预测得到的
z
^
\hat{z}
z^和真实的
z
z
z尽可能相乘更大,即更相近
InfoNCE loss代码分析:
TO BE CONTINUED
构造NCE损失
构造过程如下:
- 确定公式中的样本 i i i和正样本 v v v,从而计算出 P ( i ∣ v ) P(i|v) P(i∣v)(这一步还是比较容易的)
- 确定负样本服从的分布及采样方式。这一步通常是较困难的,它往往跟特定任务有关;比如InstDisc这篇文章中,就是以 1 n \frac{1}{n} n1这个均匀分布作为样本服从的分布, n n n为类别数(因为是个体判别任务,所以也是样本数);而NCE代码中介绍的RNN语言模型任务,就是以每个单词在训练集中出现的频率作为选择负样本的分布;我的任务是一个检索任务,一般来说,检索任务的正样本和负样本都是现成的,可以直接用,但是负样本的选取往往会影响模型最终的性能,负样本与正样本的区分难度越大,模型越容易competitive
- 调整 m m m、 τ \tau τ等超参数以适应特定网络