Learning to learn——Meta learning
Meta Learning 最常被用来解决少样本(Few-Shot)的问题,在这边我们介绍一篇经典的论文 Model-Agnostic Meta-Learning(MAML)。由题目可知他是一种「与模型无关的」元学习,亦即这种方法可以匹配任何使用梯度下降算法(Gradient Descent)训练的模型,并能应用于各种不同的学习问题,如分类、回归和强化学习等。
MAML算法的目的
在 MAML 中,其目标在于一次看过多种任务(task),并希望可以学到一个可以找到所有任务「本质」的模型。举例来说,我们小的时候学会宝特瓶可以一手握着瓶身,另一手将瓶盖转开;而当我们接触到一个装糖果的玻璃罐时,我们察觉玻璃罐与保特瓶相似的本质,因而有办法套用既往的知识快速的移转到新的任务上,而MAML便是在学这个过程,在遍览多种任务后,学习一组对任务敏感的参数,当新任务进来时能快速的将先验知识移转到新任务中。
相对于deep learning在一个task(任务)中通过对样本的学习以对新样本做出判断,元学习的目标可以看做是将task视作样本,通过对多个task的学习,以使元模型(meta-learner)能够对新的task做出快速而准确的学习。具体来说,就是训练能"对特定的task产生特定的高效学习算法的算法"。至于MAML,则是尝试训练一个最简单的算法——参数初始化。MAML希望训练一组初始化参数,通过在初始参数的基础上进行一或多步的梯度调整,来达到仅用少量数据就能快速适应新task的目的。当我们通过MAML得到了一组蛮不错的参数,之后在类似的任务中,这组参数将会提供很好的模型初始迭代点。
算法步骤
- Sample batch size of tasks:首先会从meta-training里面筛选一个batch size的training data。
- Evaluate gradient and compute adapted parameter:对 training data 中每一个 task 以及其对应的 label 计算属于每个 Task 的 gradient 与更新后的 model 参数。
- Update the model:当有了每个task 利用training data of meta-train得到的新模型参数后,可以利用test data of meta-train验证,并且加总所有任务的loss,对原本模型参数微分并真正的更新一次参数。
如何评估一组初始化参数的好坏呢?最直觉的想法自然是用它和task的训练集来训练模型,看最后得到的正确率和所需要的迭代次数。但是一般的深度模型训练常常要花费几万次的迭代来得到一个可靠的解,尽管我们可以像RNN的BPTT算法一样把这几万次的过程中每一步的参数对应的梯度都考虑进去,进而更新初始化参数,但是这个过程需要的时间和空间开销都大得惊人,我们有一种更高效的方式,就是只进行一次参数更新,用这时的参数来计算误差,更新初始化参数。
解这样的优化问题,得到的将是一个"在所有任务上,经过一次梯度下降更新后,total loss最小的初始化参数"。尽管我们不能说这是一个最好的初始化参数,但是我们可以相信这个参数将会帮助我们训练更多的类似task。
具体到代码实现,考虑到task可能非常多,则是一般采取每次随机抽取一个task,把参数代入模型,迭代更新一次;更新到第二次时,用这个Δ直接更新我们的初始化参数。
这里使用了一种近似,设初始参数为Φ,则单次更新后的模型参数为
其中L1是第一次计算loss时的损失函数(损失函数会随着参数变化而变化)。当计算第二次更新时,我们要计算这时的Φ关于Loss2的导数,涉及到二阶导数,为了快速计算,我们直接用
的一阶近似扔掉二阶导数,这样计算就变得简单很多,每次我们只需要把初始参数(Meta)用于模型初始化,在训练集上训练一次更新参数,然后在计算第二次导数时,把这个导数拿出来用于更新我们的初始参数(Meta).
代码实现
论文上给出了一组非常简单的训练任务集,我们要实现的就是生成 a∗sin(x+b) 的数据集,其中a,b可以调整以得到多种相似但不相同的任务。a,b 的范围是(0,1.5) (0,2π),每个数据集有10个点。在这些数据集中,训练一个最契合所有任务的初始化值。
(先调包,我可懒得自己写梯度下降)
import torch
import torch.nn as nn
import torch.utils.data as data
import torch.nn.functional as F
import numpy as np
from tqdm import tqdm
import copy
import matplotlib.pyplot as plt
然后我们设计一个函数产生我们需要的数据集,通过随机产生a和b,就能获得多组不同的tasks。我们这里设置每个任务的数据size为10
device = 'cpu'
def meta_task_data(seed = 0, a_range=[0.1, 5], b_range = [0, 2*np.pi], task_num = 100,
n_sample = 10, sample_range = [-5, 5], plot = False):
np.random.seed = seed
a_s = np.random.uniform(low = a_range[0], high = a_range[1], size = task_num)
b_s = np.random.uniform(low = b_range[0], high = b_range[1], size = task_num)
total_x = []
total_y = []
label = []
for t in range(task_num):
x = np.random.uniform(low = sample_range[0], high = sample_range[1], size = n_sample)
total_x.append(x)
total_y.append( a_s[t]*np.sin(x+b_s[t]) )
label.append('{:.3}*sin(x+{:.3})'.format(a_s[t], b_s[t]))
if plot:
plot_x = [np.linspace(-5, 5, 1000)]
plot_y = []
for t in range(task_num):
plot_y.append( a_s[t]*np.sin(plot_x+b_s[t]) )
return total_x, total_y, plot_x, plot_y, label
else:
return total_x, total_y, label
bsz = 1
train_x, train_y, train_label = meta_task_data()
train_x = torch.Tensor(train_x).unsqueeze(-1) # add one dim
train_y = torch.Tensor(train_y).unsqueeze(-1)
train_dataset = data.TensorDataset(train_x, train_y)
train_loader = data.DataLoader(dataset=train_dataset, batch_size=bsz, shuffle=False)
test_x, test_y, plot_x, plot_y, test_label = meta_task_data(task_num=1, n_sample = 10, plot=True)
test_x = torch.Tensor(test_x).unsqueeze(-1) # add one dim
test_y = torch.Tensor(test_y).unsqueeze(-1) # add one dim
plot_x = torch.Tensor(plot_x).unsqueeze(-1) # add one dim
test_dataset = data.TensorDataset(test_x, test_y)
test_loader = data.DataLoader(dataset=test_dataset, batch_size=bsz, shuffle=False)
有了上面的推导,实现起来也并不困难。我们知道了只需要用第二次梯度下降的更新直接应用到meta上,就可以另外找一个变量把meta存起来,每次要用就用它初始化模型。在第一次和第二次更新参数时,把参数记录下来取差值,直接加到meta上,就这么简单。
在此之前还要先定义模型,我们定义1-50-50-1的全连接网用来处理上面的所有任务。
# 定义模型,输入输出为1维,双隐层,隐层单元50个
class net(nn.Module):
def __init__(self):
super(net, self).__init__()
self.fc1 = nn.Linear(1, 50)
self.sig1 = nn.Sigmoid()
self.fc2 = nn.Linear(50, 50)
self.sig2 = nn.Sigmoid()
self.fc3 = nn.Linear(50, 1)
def forward(self, x):
out = self.fc1(x)
out = self.sig1(out)
out = self.fc2(out)
out = self.sig2(out)
out = self.fc3(out)
return out
为了更方便地进行张量加减,我这里把meta用一个一维的张量表示;如此,我们要额外定义从一维张量生成模型,以及从模型获得一维张量的函数。
def model_to_array(model):
'''
这个函数把所有模型参数一字排开,输出一个1维的tensor
'''
s_dict = model.state_dict()
return torch.cat(
(
s_dict['fc1.weight'].view(-1),
s_dict['fc1.bias'].view(-1),
s_dict['fc2.weight'].view(-1),
s_dict['fc2.bias'].view(-1),
s_dict['fc3.weight'].view(-1),
s_dict['fc3.bias'].view(-1))
)
def array_to_model(model,arr):
'''
这个函数把1维的tensor的数据写回model的dict中
'''
indice = 0
s_dict = model.state_dict()
for name,param in s_dict.items():
length = torch.prod(torch.tensor(param.shape))
s_dict[name] = arr[indice:indice+length].view(param.shape)
indice += length
model.load_state_dict(s_dict)
开始训练,我们把所有的tasks过5000个epoch,使用随机梯度下降,更新meta和更新模型参数的学习率都设为0.01
model = net() # 正式的模型,用于在各个task上测试
protortype_prarms = model_to_array(model) # 我们要更新的原型参数,也就是MAML要训练的参数
EPOCH = 5000
Prototype_LR = 0.01
Training_LR = 0.01
optimizer = torch.optim.SGD(model.parameters(), lr=Training_LR)
proto_optimizer = torch.optim.SGD(model.parameters(), lr=Prototype_LR)
loss_func = nn.MSELoss()
for epoch in range(EPOCH):
total_loss = 0
total_times = 0
for step, (X,y) in enumerate(train_loader):
X = X.view(10,1)
y = y.view(10,1)
# 把prototype的参数导入模型,作为初始化
array_to_model(model,protortype_prarms)
# 先计算一次,更新一次参数
yhat = model(X)
loss = loss_func(yhat,y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
params_first_step = model_to_array(model)
# 然后做第二次计算,再更新一次参数,把这次更新的差值用于更新原型参数
yhat = model(X)
loss = loss_func(yhat,y)
total_loss += loss.item()
proto_optimizer.zero_grad()
loss.backward()
proto_optimizer.step()
params_second_step = model_to_array(model)
total_times += 1
# 计算差值,更新protortype_prarms
protortype_prarms += params_second_step
protortype_prarms -= params_first_step
if (epoch+1)%20==0:
print("Epoch %d, loss:%.2f"%(epoch+1,total_loss/total_times))
我们可以画图来看一看meta的参数有没有发挥作用
# 我们可以用两张图来看一看meta的参数有没有发挥作用
optimizer = torch.optim.SGD(model.parameters(), lr=Training_LR)
fig = plt.figure(figsize = [9.6,7.2])
ax = plt.subplot(111)
plot_x1 = plot_x.squeeze().numpy()
ax.scatter(test_x.numpy().squeeze(), test_y.numpy().squeeze())
ax.plot(plot_x1, plot_y[0].squeeze(),label = 'origin')
# 丢入without train的model看一看
plot_y_without_train = model(plot_x.view(1000,1))
ax.plot(plot_x1, plot_y_without_train.detach().numpy().squeeze(),label = 'meta')
# train一个step,再观察输出
yhat = model(test_x[0])
loss = loss_func(yhat,test_y[0])
optimizer.zero_grad()
loss.backward()
optimizer.step()
plot_y_with_one_step = model(plot_x.view(1000,1))
ax.plot(plot_x1, plot_y_with_one_step.detach().numpy().squeeze(),label = '1 step')
# train 10个step,再观察输出
for step in range(10):
yhat = model(test_x[0])
loss = loss_func(yhat,test_y[0])
optimizer.zero_grad()
loss.backward()
optimizer.step()
plot_y_with_ten_step = model(plot_x.view(1000,1))
ax.plot(plot_x1, plot_y_with_ten_step.detach().numpy().squeeze(),label = '10 step')
ax.legend()
初始化后的函数已经有点正弦函数的样子了,只需要再train一个step,就能让数据点和网络输出相当接近,如果train10个epoch,就基本完全收敛在所有的数据点上。这是一般的参数初始化完全无法做到的,这就是meta学习到的知识。
更直观的,我们画出normal的初始化和meta的初始化的loss图。
在类似的任务上有更快的收敛速度,就是meta-learning的力量。
小结
懒得写了,有人看记得点个赞呗