一、前言

深度神经网络通常比较擅长从高维数据中学习,例如图像或者语言,但这是建立在它们有大量标记的样本来训练的情况下。然而,人类却拥有单样本学习的能力——如果你找一个从来没有见过小铲刀的人,给他一张小铲刀的图片,他应该就能很高效的将它从其他厨房用具里面鉴别出来。

pytorch 孪生神经网络 示例 孪生神经网络算法_深度学习

这是一种对人类来说很容易的任务,但是直到我们想写一个算法让它去做这件事……那就GG了 。很明显,机器学习系统很希望拥有这种快速从少量样本中去学习的能力,因为收集和标记数据是一个耗时费力的工作。

最近涌现出来很多有趣的基于神经网络的单样本学习论文,它们已经得到了一些不错的结果。

二、定义问题:N类别单样本学习(One-shot Learning)

在我们解决任何问题之前,我们应该精准的定义出这个问题到底是什么,下面是单样本分类问题的符号化表示: 我们的模型只获得了很少的标记的训练样本S,它有N个样本,每个相同维度的向量x 有一个对应的标签 y

pytorch 孪生神经网络 示例 孪生神经网络算法_深度学习_02


再给出一个待分类的测试样例x。因为样本集中每个样本都有一个正确的类别 y,我们的目标是正确的预测 y∈S 中哪一个是的正确标签。

这里有很多种定义问题的方式,但上面是我们的定义,注意这里有一些事项需要记录一下:

  • 现实生活中可能约束更少,可能一张图片并不见得只有一个正确的类别
  • 这个问题很容易泛化到 k-shot 学习,我们只需要把每个类别 y仅有单个样本换成 k 个样本就可以了
  • 当N很高时候,x 可能有更多可能的类别,所以正确预测类别更难
    随机猜的正确率是

这里有一些在Omniglot数据集上单样本学习的例子,我会在下一部分介绍它。图示分别为9类,25类,36类的单样本学习任务。

pytorch 孪生神经网络 示例 孪生神经网络算法_数据_03

Omniglot数据集拥有50种文字,1623类手写字符。对于每类字符仅有20个样本,每个样本分别由不同的人绘制而成,分辨率为105*105。

pytorch 孪生神经网络 示例 孪生神经网络算法_神经网络_04

上面是Omniglot数据集的一些例子,如图所示,这里有很多种字符,如果你喜欢机器学习,你肯定听说过 MNIST 数据集。Omniglot有时被成为 MNIST 的转置,因为它有1623类字符,每类只有20个样本,相比 MNIST 的10个类别,每个类别都有上千样本,正好相反。Omniglot 还有创作的笔画数据,但是我们这里用不到它。

通常,我们把样本分为30类训练样本,剩下20类作为评估。所有这些不同的字符可以组成很多种单样本学习任务,所以它确实是单样本学习的一个很好的评估标准。

三、一个单样本学习的 Baseline:1 近邻

最简单的分类方式是使用 k-近邻方法,但是因为每个类别只有一个样本,所以我们需要用 1近邻。这很简单,只需要计算测试样本与训练集中每个样本的欧式距离,然后选择最近的一个就可以了:

pytorch 孪生神经网络 示例 孪生神经网络算法_深度学习_05

根据Koch等人的论文,在Omniglot数据集中的20类上,单样本分类,1-nn可以得到大约28%的精度,28%看起来很差,但是它已经是随机猜测(5%)的 6 倍了。这是一个单样本学习算法最好的baseline 或者“合理性测试”了。

Lake等人的 Hierarchical Bayesian Program Learning,层次贝叶斯程序学习(以下简称 HBPL)得到了大约 95.2%的精度,非常不错。我只看懂了30%,但它非常有趣,它与深度学习直接从原始像素上训练相比,是风马牛不相及的,因为:

  • HBPL使用笔画数据,而不是仅仅用原始像素;
  • HBPL在Omniglot数据集上学习一个笔画的生成模型,这个算法需要更加复杂的标注,所以不像深度学习能直接从狗、卡车、大脑扫描图以及小铲子等图片的原始像素上去做单样本学习,这种图片也不是由笔画构成的。

Lake等人也指出,人类可以在Omniglot 数据集 20类样本上达到 95.5%的精度,仅仅比 HBPL高一点。

四、使用深度神经网络来做单样本学习

如果我们单纯的训练一个用交叉熵损失的softmax分类器神经网络来做单样本学习,很明显,网络会严重过拟合。即便是每类给出上百个样本,现代的神经网络依然会过拟合。深度网络有百万级别的参数来拟合训练数据,所以它们可以学习到一个巨大的函数空间(正式来说,是因为它们有一个很高的VC维,这就是为什么它们可以很好的从复杂的高维数据中学习的部分原因)。

很不幸的是,神经网络这个优势又成为了它们做单样本学习的一大障碍。当有百万级的参数需要做梯度下降,有这么多可能学习到的映射关系,我们怎么能设计一个网络,让他可以从单个样本去学习呢?

人类很容易从单个样本就能学会小铲刀或者字母Θ的意思,因为我们一辈子一直都在从相似对象中观察和学习。把一个随机初始化的神经网络与人类这种花了一辈子时间去识别物体和符号相比,的确不太公平,因为随机初始化的神经网络对数据的映射结构缺乏先验。这也是为什么我看到的单样本学习论文都是采用的从其他任务上的知识迁移方法。

神经网络非常擅长从结构化的复杂/高维数据中(例如图像)提取特征。如果给神经网络与单样本学习任务相似的训练数据,它或许能够从这些数据中学习到有用的特征,这些特征可能不需要调整就能用到单样本学习。这样,我们仍旧能叫他单样本学习,因为辅助的训练数据与单样本测试的数据不是相同的类别。(注意:这里的特征指的是“被用来训练的数据的映射数据”——译者注:例如经过CNN提取到的特征)。

接下来以一个有趣的问题就是,我们如何设计一个神经网络让它来学习特征?最显而易见的方法就是用迁移学习(如果有标记数据的话)——在训练数据上训练一个softmax分类器,然后在单样本学习任务的数据集上微调最后一层的权重。实际上,神经网络分类器在Omniglot数据集上不会有什么良好的表现,因为每类的样本仅有几个,即使是微调最后一层的权重,网络也会在训练集上过拟合。但这种方法也比使用L2距离的 k-近邻方法要好很多了(参考Matching Networks for One shot learning 中对各种单样本学习方法的效果的比较)。

这里还是有一种方法来做单样本学习的!忘了1近邻方法?这个简单的,非参的单样本学习器,计算测试集中的样本与训练集中每个样本的L2距离,并选择最近的作为它的类别。这种方法是ok的,但是L2距离会陷入严重的维度灾难问题,所以它在成千维的数据上(像Omniglot)上表现不太好。

另外,如果你有两个接近相同的图片,如果你把其中一张图片的像素向右移动一点,那么两张图片的L2距离会从0一下子变得非常高。L2距离在这种任务上是一个非常糟糕的度量。深度学习能奏效吗?我们可以使用深度卷积神经网络来学习一种非参的近邻分类器可以使用的相似性函数。

五、孪生网络(Siamese Networks)

Koch 等人的单样本学习方法是同时给神经网络两张图片以让他来猜测两张图片是否是同一个类别。当我们做上面提到的单样本分类任务的时候,网络可以比较测试集与训练集中的每张图片,然后挑选出哪一张与它最可能是同样类别。所以我们想让神经网络架构同时输入两张图片,输出它们属于同一个类别的概率。

假设x1和x2是数据集中的2个类别,我们让 [x1 o x2]表示 x1和 x2是同一个类别。注意,[x1 o x2][x2 o x1]是等价的——这意味这如果我们颠倒输入图片的顺序,输出的概率是完成相同的,p([x1 o x2])p([x2 o x1])相等。这被称为对称性,孪生网络就是依赖它设计的。

对称性是非常重要的,因为它要学习一个距离度量——x1到x2的距离应该等于x2到x1的距离。

如果我们仅仅把两个样本拼接起来,把它作为神经网络的单一的输入,每个样本将会是与一个不同权重集合的矩阵相乘(或缠绕),这会打破对称性。没问题,这样子网络依然能成功的为每个输入学习到完全相同的权重,但是对两个输入学习相同的权重会更容易一些。所以我们可以让两个输入通过完全相同,共享参数的网络,然后使用绝对差分作为线性分类器的输入–这是孪生网络必须的结构。两个完全相同的双胞胎,共用一个头颅,这就是孪生网络的由来。

六、CNN 孪生网络的网络架构

一张图像是3D像素矩阵,一个卷积层是一个神经元连接到前面一层神经元的一小部分(译者注:局部连接性,比如用3*3的卷积核),然后使用与这个神经元相同的连接权重在一张图片或者特征块上滑动一遍,生成另外一个3d的神经元。一个最大池化层是在空间上缩小特征图用的。很多这样的层按照顺序堆叠到一起就可以用梯度下降来训练了,它们在图像任务上表现良好。

Koch等人使用卷积孪生网络去分类成对的Omniglot图像,所以这两个孪生网络都是卷积神经网络。这两个孪生网络每个的架构如下:64通道的10×10卷积核,relu->max pool->128 通道的 7×7卷积核,relu->max pool->128通道的4×4卷积核,relu->max pool->256 通道的 4×4卷积核。

孪生网络把输入降低到越来越小的 3d 张量上,最终它们经过一个4096神经元的全连接层。两个向量的绝对差作为线性分类器的输入。这个网络一共有38,951,745个参数——96%的参数属于全连接层。这个参数量很大,所以网络有很高的过拟合风险,但是成对的训练意味着数据集是很大的,所以过拟合问题不会出现。

pytorch 孪生神经网络 示例 孪生神经网络算法_pytorch 孪生神经网络 示例_06


输出被归一化到[0,1]之间,使用sigmoid函数让它成为一个概率。当两个图像是相同类别的时候,我们使目标 t=1,类别不相同的时候使 t=0。它使用逻辑斯特回归来训练。这意味着损失函数应该是预测和目标之间的二分类交叉熵。损失函数中还有一个L2权重衰减项,以让网络可以学习更小的或更平滑的权重,从而提高泛化能力:

pytorch 孪生神经网络 示例 孪生神经网络算法_深度学习_07


当网络做单样本学习的时候,孪生网络简单的分类一下测试图像与训练集的图像中哪个最相似就可以了:

pytorch 孪生神经网络 示例 孪生神经网络算法_数据集_08


这里使用argmax而不是近邻方法中的argmin,因为类别越不同,L2度量的值越高,但是这个模型的输出p([x1 o x2]),所以我们要这个值最大。这个方法有一个明显的缺陷:对于训练集中的任何一个x,概率[x^ o x]与训练集中每个样本都是独立的!这意味着概率值的和不为 1。测试图像与训练图像应该是相同类型的……

七、观察:成对训练的有效数据集的大小

采用逐对训练,将会有平方级别对的图像对来训练模型,这让模型很难过拟合,假设我们有E类,每类有C个样本。一共有 C⋅ E 张图片,总共可能的配方数量可以这样计算:

pytorch 孪生神经网络 示例 孪生神经网络算法_数据_09

对于Omniglot中的964类(每类20个样本),这会有185,849,560个可能的配对,这是巨大的!然而,孪生网络需要相同类的和不同类的配对都有。每类E个训练样本,所以每个类别有

pytorch 孪生神经网络 示例 孪生神经网络算法_神经网络_10

对,这意味着这里有

pytorch 孪生神经网络 示例 孪生神经网络算法_神经网络_11

pytorch 孪生神经网络 示例 孪生神经网络算法_深度学习_12

个相同类别的配对。

对于Omniglot有183,160对。即使 183,160对已经很大了,但只是所有可能配对的千分之一,因为相同类别的配对数量随着E平方级的增大,但是随着C是线性增加。这个问题非常重要,因为孪生网络训练的时候,同类别和不同类别的比例应该是1:1。或许,它表明逐对训练在那种每个类别有更多样本的数据集上更容易训练。

八、代码

下面是模型定义,如果你见过keras,那很容易理解。我只用Sequential()来定义一次孪生网络,然后使用两个输入层来调用它,这样两个输入使用相同的参数。然后我们把它们使用绝对距离合并起来,添加一个输出层,使用二分类交叉熵损失来编译这个模型。

from keras.layers import Input, Conv2D, Lambda, merge, Dense, Flatten,MaxPooling2D
from keras.models import Model, Sequential
from keras.regularizers import l2
from keras import backend as K
from keras.optimizers import SGD,Adam
from keras.losses 
import binary_crossentropy
import numpy.random as rng
import numpy as np
import os
import dill as pickle
import matplotlib.pyplot as plt
from sklearn.utils import shuffle

def W_init(shape,name=None):
    """Initialize weights as in paper"""
    values = rng.normal(loc=0,scale=1e-2,size=shape)
    return K.variable(values,name=name)#//TODO: figure out how to initialize layer biases in keras.

def b_init(shape,name=None):
    """Initialize bias as in paper"""
    values=rng.normal(loc=0.5,scale=1e-2,size=shape)
    return K.variable(values,name=name)

input_shape = (105, 105, 1)
left_input = Input(input_shape)
right_input = Input(input_shape)
#build convnet to use in each siamese 'leg'
convnet = Sequential()
convnet.add(Conv2D(64,(10,10),activation='relu',input_shape=input_shape,
                   kernel_initializer=W_init,kernel_regularizer=l2(2e-4)))
convnet.add(MaxPooling2D())
convnet.add(Conv2D(128,(7,7),activation='relu',
                   kernel_regularizer=l2(2e-4),kernel_initializer=W_init,bias_initializer=b_init))
convnet.add(MaxPooling2D())
convnet.add(Conv2D(128,(4,4),activation='relu',kernel_initializer=W_init,kernel_regularizer=l2(2e-4),bias_initializer=b_init))
convnet.add(MaxPooling2D())
convnet.add(Conv2D(256,(4,4),activation='relu',kernel_initializer=W_init,kernel_regularizer=l2(2e-4),bias_initializer=b_init))
convnet.add(Flatten())
convnet.add(Dense(4096,activation="sigmoid",kernel_regularizer=l2(1e-3),kernel_initializer=W_init,bias_initializer=b_init))
#encode each of the two inputs into a vector with the convnet
encoded_l = convnet(left_input)
encoded_r = convnet(right_input)
#merge two encoded inputs with the l1 distance between them
L1_distance = lambda x: K.abs(x[0]-x[1])
both = merge([encoded_l,encoded_r], mode = L1_distance, output_shape=lambda x: x[0])
prediction = Dense(1,activation='sigmoid',bias_initializer=b_init)(both)
siamese_net = Model(input=[left_input,right_input],output=prediction)
#optimizer = SGD(0.0004,momentum=0.6,nesterov=True,decay=0.0003)


optimizer = Adam(0.00006)
#//TODO: get layerwise learning rates and momentum annealing scheme described in paperworking

siamese_net.compile(loss="binary_crossentropy",optimizer=optimizer)


siamese_net.count_params()

Koch等人增加向训练集中增加失真的图像,使用150,000对样本训练模型。因为这个太大了,我的内存放不下,所以我决定使用随机采样的方法。载入图像对或许是这个模型最难实现的部分。因为这里每个类别有 20个样本,我把数据重新调整为 N_classes×20×105×105的数组,这样可以很方便的来索引。

class Siamese_Loader:
    """For loading batches and testing tasks to a siamese net"""
    def __init__(self,Xtrain,Xval):
        self.Xval = Xval
        self.Xtrain = Xtrain
        self.n_classes,self.n_examples,self.w,self.h = Xtrain.shape
        self.n_val,self.n_ex_val,_,_ = Xval.shape
    def get_batch(self,n):
        """Create batch of n pairs, half same class, half different class"""
        categories = rng.choice(self.n_classes,size=(n,),replace=False)
        pairs=[np.zeros((n, self.h, self.w,1)) for i in range(2)]
        targets=np.zeros((n,))
        targets[n//2:] = 1
        for i in range(n):
            category = categories[i]
            idx_1 = rng.randint(0,self.n_examples)
            pairs[0][i,:,:,:] = self.Xtrain[category,idx_1].reshape(self.w,self.h,1)
            idx_2 = rng.randint(0,self.n_examples)
            #pick images of same class for 1st half, different for 2nd
            category_2 = category if i >= n//2 else (category + rng.randint(1,self.n_classes)) % self.n_classes
            pairs[1][i,:,:,:] = self.Xtrain[category_2,idx_2].reshape(self.w,self.h,1)
        return pairs, targets
    def make_oneshot_task(self,N):
        """Create pairs of test image, support set for testing N way one-shot learning. """
        categories = rng.choice(self.n_val,size=(N,),replace=False)
        indices = rng.randint(0,self.n_ex_val,size=(N,))
        true_category = categories[0]
        ex1, ex2 = rng.choice(self.n_examples,replace=False,size=(2,))
        test_image = np.asarray([self.Xval[true_category,ex1,:,:]]*N).reshape(N,self.w,self.h,1)
        support_set = self.Xval[categories,indices,:,:]
        support_set[0,:,:] = self.Xval[true_category,ex2]
        support_set = support_set.reshape(N,self.w,self.h,1)
        pairs = [test_image,support_set]
        targets = np.zeros((N,))
        targets[0] = 1
        return pairs, targets
    def test_oneshot(self,model,N,k,verbose=0):
        """Test average N way oneshot learning accuracy of a siamese neural net over k one-shot tasks"""
        pass
        n_correct = 0
        if verbose:
            print("Evaluating model on {} unique {} way one-shot learning tasks ...".format(k,N))
        for i in range(k):
            inputs, targets = self.make_oneshot_task(N)
            probs = model.predict(inputs)
            if np.argmax(probs) == 0:
                n_correct+=1
        percent_correct = (100.0*n_correct / k)
        if verbose:
            print("Got an average of {}% {} way one-shot learning accuracy".format(percent_correct,N))
        return percent_correct

下面是训练过程

evaluate_every = 7000
loss_every=300
batch_size = 32
N_way = 20
n_val = 550

siamese_net.load_weights("PATH")best = 76.0

for i in range(900000):
    (inputs,targets)=loader.get_batch(batch_size)
    loss=siamese_net.train_on_batch(inputs,targets)
    if i % evaluate_every == 0:
        val_acc = loader.test_oneshot(siamese_net,N_way,n_val,verbose=True)
        if val_acc >= best:
            print("saving")
            siamese_net.save('PATH')
            best=val_acc
    if i % loss_every == 0:
        print("iteration {}, training loss: {:.2f},".format(i,loss))

一旦学习曲线变平整了,使用在 20 类验证集合上表现最好的模型来测试。网络在验证集上得到了大约 83%的精度,原论文精度是93%。或许这个差别是因为没有实现原论文中的很多增强性能的技巧,像逐层的学习率/冲量,使用数据失真的数据增强方法,贝叶斯超参数优化,并且我迭代的次数也不够。我并不担心这个,因为这个教程侧重于简要介绍单样本学习,而不是在那百分之几的分类性能上钻牛角夹。这里不缺乏这方面的资源。

pytorch 孪生神经网络 示例 孪生神经网络算法_数据集_13

如图所示,验证集上的精度要比训练集上差一些,尤其是当N的数量很多的时候,这里面肯定有过拟合的问题。我们也想测试一下传统的正则化方法(像dropout)在验证集与训练集完全不同的时候的表现。对于较大的N,它比我期待中的要好,在50-60种类别上,仍旧有65%的平均精度。

九、讨论

现在我们只是训练了一个来做鉴别相同还是不同的二分类网络。更重要的是,我们展现了模型能够在没有见过的字母表上的20类单样本学习的性能。当然,这不是使用深度学习来做单样本学习的唯一方式。

正如我前面提到的,我认为这个孪生网络的最大缺陷是它要拿测试图像与训练集中图像逐个比较。当这个网络将测试图像与任何图像x1相比,不管训练集是什么,

pytorch 孪生神经网络 示例 孪生神经网络算法_数据集_14

都是相同的。这很愚蠢,假如你在做单样本学习任务,你看到一张图片与测试图像非常类似。然而,当你看到训练集中另外一张图片也与测试集非常相似,你就会对它的类别没那么自信了。训练目标与测试目标是不同的,如果有一个模型可以很好的比较测试图片与训练集,并且使用仅仅有一个训练图片与之拥有相同类别的限制,那模型会表现的更好。

Matching Networks for One Shot learning 这篇论文就是做这个的。它们使用深度模型来端到端的学习一个完整的近邻分类器,而不是学习相似度函数,直接在单样本任务上训练,而不是在一个图像对上。Andrej Karpathy’s notes 很好的解释了这个问题。因为你正在学习机器分类,所以你可以把他视为元学习(meta learning)。