一. 介绍
在开始进行监督学习的时候我们需要收集一个训练集
D
t
r
=
{
(
x
i
,
y
i
)
}
i
=
1
n
D_{tr}=\{(x_i,y_i)\}^n_{i=1}
Dtr={(xi,yi)}i=1n。大部门的监督学习都假定每一个例子都是从一个固定的概率分布P中进行独立同分布的采样。监督学习的目标为构造一个模型
f
f
f去预测目标向量y通过x。为了实现这种假设,监督学习通常采用经验风险最小化原则(ERM),
f
f
f在这种情况下为
1
∣
D
t
r
∣
∑
(
x
i
,
y
i
)
∈
D
t
r
l
(
f
(
x
i
)
,
y
i
)
\frac{1}{|D_{tr}|}\sum_{(x_i,y_i) \in D_{tr}}l(f(x_i),y_i)
∣Dtr∣1∑(xi,yi)∈Dtrl(f(xi),yi)。实际上,ERM需要多次遍历训练集。
ERM是对我们认为的人类的学习的简化。然而现实中人们的观察是有序的,而且同一个例子智能记住少量的数据。因此,iid的假设和任何使用ERM原则的希望都破灭了。也就是说ERM会导致灾难性的遗忘。
本文通过缩小了ERM和人类学习的差距。值得注意的是,作者进行的学习是一个接一个,按顺序的到达。
(
x
1
,
t
1
,
y
1
)
,
.
.
.
,
(
x
i
,
t
i
,
y
i
)
,
.
.
.
,
(
x
n
,
t
n
.
y
n
)
(1)
(x_1,t_1,y_1),...,(x_i,t_i,y_i),...,(x_n,t_n.y_n) \tag{1}
(x1,t1,y1),...,(xi,ti,yi),...,(xn,tn.yn)(1)
基于这种情况下,面临着ERM所不知的挑战:
- Non-iid的输入数据:数据的连续性对于任何固定概率分布 P ( X , T , Y ) P(X,T,Y) P(X,T,Y)都是不确定的,因为一旦任务切换,就可以观察到来自新任务的一系列例子。
- 灾难性遗忘:学习新任务可能会损害学习者之前已经解决的任务中的表现
- 迁移学习:当连续的任务互相关联时,存在迁移学习的机会。这将转化为更快地学习新任务,以提高旧任务的性能。
二. 持续学习的框架
依照(1)中所描述的数据,我们假设连续的任务分布中是局部的iid,也就是每一个
(
x
i
,
t
i
,
y
i
)
(x_i,t_i,y_i)
(xi,ti,yi)都满足
(
x
i
,
y
i
)
~
i
i
d
P
t
i
(
X
,
Y
)
(x_i,y_i)~_{iid}P_{t_i}(X,Y)
(xi,yi)~iidPti(X,Y)。我们的目标为学习到一个模型
f
f
f,能够在任何时刻进行预测,这样的测试可以属于我们在过去观察到的任务,当前的任务或者我们将来在未来经历的任务。
任务描述符: 在顺序任务进行中,任务描述符至关重要。丰富的描述符为零学习提供机会,因此任务之间可以单独使用新的任务描述符推断出来。此外,任务描述符消除了相似学习任务的歧义。在本文中更加关注于减轻灾难性遗忘,从一个连续的数据学习并为未来的研究留下零学习机会。
训练协议以及评估指标
本文中,考虑的任务序列的设定如下:a.任务数量很大,b.每个任务的训练例子很少,c.学习者只观察每个任务的例子一次,d.报告了衡量迁移和遗忘的指标。
因此,在训练时候,我们每一次仅给一个例子(或一个mini-batch)给学习者。同时学习者不会经历同一个例子两次,而且热舞是按照顺序进行。不需要对任务强加任何顺序,因为未来的任务可能与过去的重合。
除了监控学习者跨任务的表现,评估学习者转移知识的能力也很重要,更具体的说,需要测量:
- 向后的迁移(BWT)。这个因子主要为学习任务t之后看t以前任务的表现。一方面,学习某些任务会提高前一个任务的性能,存在正向后迁移。而有些则会导致负的迁移。大量的负迁移就会导致灾难性遗忘。
- 向前的迁移(FWT)。这个因子则是学习任务t之后看模型在未来任务学习中的表现。
对于针对上面原则的评估,考虑对T个任务中每个任务的测试集的访问。当一个模型完成了任务
t
i
t_i
ti之后,我们需要评估他的测试表现能力对所有(T)的任务。这样做之后,我们可以构造一个矩阵
R
∈
R
T
∗
T
R\in \mathbb{R}^{T*T}
R∈RT∗T,其中
R
i
,
j
R_{i,j}
Ri,j表示为测试的准确率对于任务
t
j
t_j
tj在观察了最后一个从
t
i
t_i
ti的例子。让
b
ˉ
\bar{b}
bˉ为每个任务的随机初始化时的测试精度向量,我们可以定义者三个矩阵:
A
C
C
=
1
T
∑
i
=
1
T
R
T
,
i
(2)
ACC = \frac{1}{T}\sum_{i=1}^TR_{T,i} \tag{2}
ACC=T1i=1∑TRT,i(2)
B
W
T
=
1
T
−
1
∑
i
=
1
T
−
1
R
T
,
i
−
R
i
,
i
(3)
BWT =\frac{1}{T-1}\sum_{i=1}^{T-1}R_{T,i}-R_{i,i} \tag{3}
BWT=T−11i=1∑T−1RT,i−Ri,i(3)
B
W
T
=
1
T
−
1
∑
i
=
2
T
−
1
R
i
−
1
,
i
−
b
ˉ
i
(4)
BWT =\frac{1}{T-1}\sum_{i=2}^{T-1}R_{i-1,i}-\bar{b}_i \tag{4}
BWT=T−11i=2∑T−1Ri−1,i−bˉi(4)
三. 情景记忆梯度(GEM)
GEM中主要特征为一个情景记忆
M
t
\mathcal{M}_t
Mt,存储了从任务t中观察到的示例的子集。接下来,主要专注于通过情景记忆的有效使用来最小化负向的迁移(灾难性遗忘)。
实际汇总,学习者共有M个内存位置。如果任务数T是已知的,我们可以得出每个任务有m = M/T的内存空间。相反,如果任务数量未知,我们则需要不断减少m的值给新的任务。为了简单起见,我们假设内存是由每个任务的最后一个示例填充的,在这种情况下,可以定义从第k个任务的情景内存损失为:
l
(
f
θ
,
M
k
)
=
1
∣
M
k
∣
∑
(
x
i
,
k
,
y
i
)
∈
M
k
l
(
f
θ
(
x
i
,
k
)
,
y
i
)
(5)
l(f_\theta,\mathcal{M}_k)=\frac{1}{|\mathcal{M}_k|}\sum_{(x_i,k,y_i)\in \mathcal{M}_k}l(f_\theta(x_i,k),y_i) \tag{5}
l(fθ,Mk)=∣Mk∣1(xi,k,yi)∈Mk∑l(fθ(xi,k),yi)(5)
显然,最小化当前的损失再加上(5)会导致在
M
\mathcal{M}
M中存在过拟合。作者将使用损失(5)作为不等式的约束,避免其增加但允许减少。也就是下面这个描述:
min
θ
l
(
f
θ
(
x
,
t
)
,
y
)
其
中
l
(
f
θ
,
M
k
)
<
=
l
(
f
θ
t
−
1
,
M
k
)
f
o
r
a
l
l
k
<
t
,
(6)
\begin{aligned} \min_{\theta} \ \ \ &l(f_\theta(x,t),y) \\ 其中\ \ &l(f_\theta,\mathcal{M}_k)<=l(f_\theta^{t-1},\mathcal{M}_k) \ for\ all\ k<t,\tag{6} \end{aligned}
θmin 其中 l(fθ(x,t),y)l(fθ,Mk)<=l(fθt−1,Mk) for all k<t,(6)
接下来,作者描述了两个重要的特征去处理问题(6)。第一是,没有必要去存储旧的模型
f
θ
t
−
1
f_\theta^{t-1}
fθt−1,只要我么保证每次更新后,之前的任务损失不会增加。第二,假设函数是局部线性的,记忆是代表例子过去的任务,我们可以诊断之前损失增加的任务,计算他们损失向量之间的夹角。因此(6)可以表述为:
<
g
,
g
k
>
:
=
<
∂
l
(
f
θ
(
x
,
t
)
,
y
)
∂
θ
,
∂
l
(
f
θ
,
M
k
)
∂
θ
>
>
=
0
,
f
o
r
a
l
l
k
<
t
(7)
<g,g_k>:=<\frac{\partial l(f_\theta(x,t),y)}{\partial\theta},\frac{\partial l(f_\theta,\mathcal{M}_k)}{\partial\theta}>\ >=0,for\ all\ k<t \tag{7}
<g,gk>:=<∂θ∂l(fθ(x,t),y),∂θ∂l(fθ,Mk)> >=0,for all k<t(7)
如果所有的都满足(7),那么参数更新g不可能增加之前任务的损失。另外,如果违反了一个或多个的话,那么至少有一个先前的任务在参数更新后损失会增加。那么可以将违反的梯度g投射到最接近的梯度
g
~
\tilde{g}
g~(用平方的l2规范)。因此,到这里,关注点变为:
min
g
~
1
2
∣
∣
g
−
g
~
∣
∣
2
2
满
足
<
g
~
,
g
k
>
>
=
0
f
o
r
a
l
l
k
<
t
(8)
\min_{\tilde{g}} \frac{1}{2}\ \ ||g-\tilde{g}||^2_2 \\满足<\tilde{g},g_k>\ \ >=0\ for\ all\ k<t \tag{8}
g~min21 ∣∣g−g~∣∣22满足<g~,gk> >=0 for all k<t(8)
到这一步可以发现我们只需要变换我们的
g
~
\tilde{g}
g~使得其与每一个之前任务的向量夹角成锐角即可,而如何变化这个呢,作者将上述问题转换成2次规划的问题,然而转换后计算量过大,因此作者转换为2次规划的对偶问题求解。
min
z
1
2
v
T
G
G
T
−
g
T
z
+
g
T
G
T
v
s
u
b
j
e
c
t
t
o
v
>
=
0
(9)
\min_z\ \frac{1}{2}v^TGG^T-g^Tz+g^TG^Tv \\ subject\ to \ \ v >=0 \tag{9}
zmin 21vTGGT−gTz+gTGTvsubject to v>=0(9)
计算出v之后,我们可以获得我们新的
g
~
=
G
T
v
+
g
\tilde{g} = G^Tv+g
g~=GTv+g
具体算法过程如下:
(图里的11对应我们的公式9)
四. 代码解析
作者的代码地址点这里
大家看完后肯定对二次规划那里比较疑惑,其实如果不是专门研究算法的,可以不用那么在意,因为我们只需要调用库即可。
再开始代码前,我们首先想一想这个论文和普通的DNN有什么区别。第一是,我们需要存储之前的任务(也就是开辟一个空间存储以前训练过的部分数据)。第二是,在进行求解损失时,需要对存储过的任务依次进行损失计算,并获得任务对应的梯度。第三就是,根据二次规划问题变换我们当前任务的梯度。
理清之后,我们就根据不同点一次进行代码解读。
首先是开辟空间存储数据,也就是拿一个列表(或数组)存一部分x和y
(关键代码对应的地址为 model/gem/observe)
## 当前训练的一个batch的数据(这里为10个)
bsz = y.data.size(0)
## 求出当前任务存储是否超过了内存限制(这里为每个任务最多存256个)
endcnt = min(self.mem_cnt + bsz, self.n_memories)
## 求出当前能存多少个(这里为<=10)
effbsz = endcnt - self.mem_cnt
## 存数据
self.memory_data[t, self.mem_cnt: endcnt].copy_(
x.data[: effbsz])
if bsz == 1:
self.memory_labs[t, self.mem_cnt] = y.data[0]
else:
self.memory_labs[t, self.mem_cnt: endcnt].copy_(
y.data[: effbsz])
## 更新内存指针方便下一次(指向下一次存储的开头)
self.mem_cnt += effbsz
if self.mem_cnt == self.n_memories:
self.mem_cnt = 0
第一步做完之后,我们来看第二点不同,也就是求出之前任务的损失和梯度。
if len(self.observed_tasks) > 1:
for tt in range(len(self.observed_tasks) - 1):
self.zero_grad()
# fwd/bwd on the examples in the memory
past_task = self.observed_tasks[tt]
## 这两个变量代表y的最小值和最大值
offset1, offset2 = compute_offsets(past_task, self.nc_per_task,
self.is_cifar)
ptloss = self.ce(
self.forward(
self.memory_data[past_task],
past_task)[:, offset1: offset2],
self.memory_labs[past_task] - offset1)
ptloss.backward()
## 存储
store_grad(self.parameters, self.grads, self.grad_dims,
past_task)
最后也就是计算当前任务梯度以及找到是否有存在梯度夹角大于90度的情况:
self.zero_grad()
## 计算梯度
offset1, offset2 = compute_offsets(t, self.nc_per_task, self.is_cifar)
loss = self.ce(self.forward(x, t)[:, offset1: offset2], y - offset1)
loss.backward()
# check if gradient violates constraints
if len(self.observed_tasks) > 1:
# copy gradient
store_grad(self.parameters, self.grads, self.grad_dims, t)
indx = torch.cuda.LongTensor(self.observed_tasks[:-1]) if self.gpu \
else torch.LongTensor(self.observed_tasks[:-1])
## 计算梯度夹角
dotp = torch.mm(self.grads[:, t].unsqueeze(0),
self.grads.index_select(1, indx))
## 存在夹角大于90则进行二次规划更新
if (dotp < 0).sum() != 0:
project2cone2(self.grads[:, t].unsqueeze(1),
self.grads.index_select(1, indx), self.margin)
# copy gradients back
overwrite_grad(self.parameters, self.grads[:, t],
self.grad_dims)
self.opt.step()
最后哦我们来看看二次规划中如何传参
这里大家对照着上面的(9)来看,因为求解v的过程有具体的库,我们只需要传入对应参数即可。
当前任务梯度:g
之前所有任务的梯度:G
(注意之前所有任务的梯度
G
=
−
(
g
1
,
.
.
.
.
,
g
t
−
1
)
)
G=-(g_1,....,g_{t-1}))
G=−(g1,....,gt−1))
def project2cone2(gradient, memories, margin=0.5, eps=1e-3):
"""
Solves the GEM dual QP described in the paper given a proposed
gradient "gradient", and a memory of task gradients "memories".
Overwrites "gradient" with the final projected update.
input: gradient, p-vector
input: memories, (t * p)-vector
output: x, p-vector
"""
memories_np = memories.cpu().t().double().numpy()
gradient_np = gradient.cpu().contiguous().view(-1).double().numpy()
t = memories_np.shape[0]
## P = 1/2 *G *G^T
P = np.dot(memories_np, memories_np.transpose())
P = 0.5 * (P + P.transpose()) + np.eye(t) * eps
## q = g^T * G^T
q = np.dot(memories_np, gradient_np) * -1
G = np.eye(t)
h = np.zeros(t) + margin
v = quadprog.solve_qp(P, q, G, h)[0]
x = np.dot(v, memories_np) + gradient_np
gradient.copy_(torch.Tensor(x).view(-1, 1))
看完这篇文章,大家可以看一看我另一个解读改进GEM算法。