【啄米日常】6:keras示例程序解析(3)验证网络siamese

【啄米日常】6:keras示例程序解析(3)验证网络siamese

你们是懂我的,一般来说,在写完一篇文章后,我会悠然悠然的过个一个月,才会有内心的恐慌感【啊再不更新的话他们一定认为我根本没有学习】来督促我继续更新。

再说了,看着以往文章的点赞量和【赞赏】金额(主要是这个!),我老婆的可乐钱和薯片钱都出不来,凭什么要使劲写!

但是,今天,我要打破这个传统了,首先,上一篇文字@爱可可-爱生活 老师翻了牌子,我十分感激,并且诚惶诚恐,这让我深知不好好写文章对不起爱可可老师的厚爱。

当然,这并不是主要的,主要的是上一篇文章有这样一条暖心的评论:

啊~就像冬日里的一缕阳光,温暖了我在寒风中踽踽独行的心,还有什么能比被读者所【承认】更令人心满意足呢?比起这来,我对【赞赏】金额的耿耿于怀,显得那么的市侩和俗气(但是并不是说我就不要了啊!!赞赏我才是核心动力!)。

于是我准备加班一篇,以报答这位【没有关注我】也【没有关注我的专栏】的读者,以及更多【点赞完就算】的读者们。

本来这篇准备写一下神经网络可视化的,但是keras的神经网络可视化过于简单,体现不出我的诚意,所以今天学习一个非常实用和比较有代码技巧的一个网络siamese有同学希望我上一些nlp的文章,不要老在cv上打转。说实话nlp我懂得不多,真要写的话对着代码分析也不是不可以,但是终究有点心怯,我还是先把这一亩三分地耕好。

阅读本文你将学到:

  • Siamese网络用于验证类问题的原理
  • 如何使用泛型模型Model
  • 如何pair-wise的训练一个网络
  • 如何自定义损失函数

开始咯

Siamese网络与验证类问题

首先,这个英文单词怎么读呢~来跟我读:赛~密~死,中文意思是暹罗,所以这个网络叫暹罗网络。

胡说的,siamese确实是暹罗,但是wiki还给出另一个意思:

Siamese, an informal term for conjoined or fused, including with a two-cylinder motorcycle's exhaust pipes

也就是很像的容易混淆的, .Siamese网络用于判断两个目标是不是“很像”,也就是常说的验证类问题,比如人脸验证, 指纹验证等。

今天这个例子我们来判断两个数字是否是同一个,相关文献是杨乐坤(LeCun)于06年发表的文章Dimensionality Reduction by Learning an Invariant Mapping。

算法描述

Simese网络长啥样呢?确切的说Simese是一种处理验证类问题的方法,而不是一个具体的网络,它的思路非常直接:

(1)用一个网络提取特征,这个特征要具有足够的鲁棒性和判别性,也就是对一张图片而言,旋转、扭曲、加噪声等变换后,图像出来的特征要相似。对不同图片而言,他们的特征要不相似。这一点跟图片分类的要求是一致的。

(2)通过某个标准来衡量两张图片的相似性,进行是/不是的判决

这里我们用一个简单的MLP作为特征提取的网络,所以整个网络的图是:

显而易见,既然只是对特征进行相似度判断,那么只要用一个网络就可以了。网络的作用就是提取特征。

但是如果用一个网络的话,就要分别调用两次前向运算,然后计算损失,然后再回传回去。整个过程需要人工控制,写起来非常麻烦。

所以我们将网络做成一个多输入的模型,两个输入流经同一个网络获得运算结果。可以看作是共享同一套权重的两个网络。

这是结构,再说训练。

网络的作用是判断两张输入图片是否相似,怎么训练呢?很容易想到,训练样本的正例是一对相同标签的图片,训练样本的负例是一对标签不同的图片。这种用成对成对的样本训练的方式一般称为pair-wise的训练,这与常规的网络训练方式是很不同的(更凶狠的还有3个一组的训练,四个一组的训练……),如何组织训练样本,我们稍后代码上说。

最后一个问题是损失函数,这个其实是Siamese比较关键的地方。感性的想,损失函数应该满足下面两个性质:

  • 对两张相同标签的图片,相似性越大,损失函数的值就越小
  • 对两张不同标签的图片,相似性越小,损失函数的值就越小

不妨设两个图片的输出特征分别为F_1F_2,它们两个的相似度我们不妨用欧式距离来表示,就记作D(F_1,F_2)吧,那么,我们的损失函数要做到,当D(F_1,F_2)比较小(特征相似),并且即两个图片确实标签相同时,值比较小。D(F_1,F_2)比较小,但两个图片标签却不一样时输出比较大,D(F_1,F_2)大的时候也是同样的道理。

当然,深度学习中的损失函数嘛,一定要可导,这个是必然的。

Siamese网络的损失函数定义如下:

L(F_1,F_2,Y)=\frac{1}{2} (1-Y)D(F_1,F_2)^2+\frac{1}{2}Ymax\{0,m-D(F_1,F_2)\}^2

看着麻烦,其实不麻烦。Y是标签,只有两种情况,=1表示两个图片标签不同,=0表示相同。对于Y=0的情况,第二项为0,第一项直接变成两个特征的距离平方,显然是距离越近值越小,距离越远值越大。

对Y=1的情况,第一项为0,第二项是一个hinge loss,多了个平方而已,学过SVM的都见过。当距离小于m的时候,就会获得一个m-D(F_1,F_2)的惩罚,但是当距离大于m的时候,就没有惩罚了。距离越大惩罚越小,刚好对应两张图片类别不同时我们希望的情况。

我们下面要进行Keras代码实现,盘点一下,目前的难点主要有三个:

  • 如何进行权值共享
  • 如何进行pair-wise的训练
  • 如何定义损失函数

Keras实现

第一步还是准备数据。

本例所用的数据集是MNIST手写数字数据集,我想不知道这个数据集的应该很少,所以我就不多嘴介绍了。通过keras自带的dataset模块可以轻松导入:

(X_train, y_train), (X_test, y_test) = mnist.load_data()
X_train = X_train.reshape(60000, 784) #将图片向量化
X_test = X_test.reshape(10000, 784)
X_train = X_train.astype('float32')
X_test = X_test.astype('float32')
X_train /= 255 # 归一化
X_test /= 255

我们这里的网络是一个简单全连接网络,所以需要讲原来的2D图片向量化为一条向量,一共是6W的训练集和1W的测试集。

然后我们搭建网络,如何在Keras中搭建一个权值共享的网络呢?有一个概念我每次写文章都要强调,那就是Keras模型/层是张量到张量的映射,只要保证两个输入经由同样的计算图得到输出,那么显然就可以达到权值共享的作用——实际上它们就是一个两输入一输出的网络。

首先搭建一个简单的全连接网络:

def create_base_network(input_dim):
    '''Base network to be shared (eq. to feature extraction).
    '''
    seq = Sequential()
    seq.add(Dense(128, input_shape=(input_dim,), activation='relu'))
    seq.add(Dropout(0.1))
    seq.add(Dense(128, activation='relu'))
    seq.add(Dropout(0.1))
    seq.add(Dense(128, activation='relu'))
return seq

注意我们将它写成了函数,下面调用这个函数得到一个全连接的模型,我们用这个模型进行特征提取:

base_network = create_base_network(input_dim)

多输入的模型需要用功能更强大的Model进行建模,首先声明两个输入张量,然后将它们连接在上面的模型之前,最后搞一个输出出来:

input_a = Input(shape=(input_dim,))
input_b = Input(shape=(input_dim,))

# because we re-use the same instance `base_network`,
# the weights of the network
# will be shared across the two branches
processed_a = base_network(input_a)
processed_b = base_network(input_b)

distance = Lambda(euclidean_distance, output_shape=eucl_dist_output_shape)([processed_a, processed_b])

model = Model(input=[input_a, input_b], output=distance)

等……等等,base_network不是一个模型吗?那base_network(input_a)又是什么鬼?

问这个问题的人,肯定没有看过本专栏的一个不负责任的Keras介绍【下】

Keras的Layer也好,model也好,规定的都是输入张量到输出张量的映射,它们都是像函数一样callable的。意思就是,输入张量input_a经过一个网络base_network的作用和,得到了输出张量processed_a。

上面的代码还使用了一个Lambda层来计算两个特征的欧式距离,Lambda层通常用来完成功能比较简单,不含有可训练参数的计算需求。如果Lambda层的输出张量的shape改变了,需要设置输出变量的shape,或传入一个用于计算输出张量的shape的函数。

上面Lambda层所用到的计算欧式距离和计算输出shape的函数定义如下,因为它们是计算图的一部分,显然需要用纯tensor语言编写:

def euclidean_distance(vects):
    x, y = vects
    return K.sqrt(K.sum(K.square(x - y), axis=1, keepdims=True))


def eucl_dist_output_shape(shapes):
    shape1, shape2 = shapes
return (shape1[0], 1)

最后,调用Model将计算图概括起来,包装为一个真正的Keras model。至此,我们的模型就搭建完毕了。

模型搭建完毕,还需要编写刚才的loss,编写自己的loss是一门技术活,主要是符号式语言编写起来太蛋疼,写完还不怎么拿得准。但这真没办法,loss函数作为计算图的一部分,是必须用这种语言写好的。

def contrastive_loss(y_true, y_pred):
    '''Contrastive loss from Hadsell-et-al.'06
    http://yann.lecun.com/exdb/publis/pdf/hadsell-chopra-lecun-06.pdf
    '''
    margin = 1
return K.mean(y_true * K.square(y_pred) + (1 - y_true) * K.square(K.maximum(margin - y_pred, 0)))

仔细看看,return的那个东西就是我们刚才定义的loss function,hinge loss的参数m设置为1.

然后我们需要组织数据,生成一对对的正样本和一对对的负样本以供训练。正样本对从来自同一标签的样本集中抽取,负样本对从不同标签的样本集中抽取。

首先获得各个类别的样本下标,即按照类别来对样本集分组:

digit_indices = [np.where(y_train == i)[0] for i in range(10)]

digits_indicse的计算结果应为list of numpy,依次是每个类别的样本的下标。我们根据这些下标来选出正样本和负样本:

def create_pairs(x, digit_indices):
    '''Positive and negative pair creation.
    Alternates between positive and negative pairs.
    '''
    pairs = [] #一会儿一对对的样本要放在这里
    labels = []
    n = min([len(digit_indices[d]) for d in range(10)]) - 1
    for d in range(10):
        #对第d类抽取正负样本
        for i in range(n):
            # 遍历d类的样本,取临近的两个样本为正样本对
            z1, z2 = digit_indices[d][i], digit_indices[d][i+1]
            pairs += [[x[z1], x[z2]]]
            # randrange会产生1~9之间的随机数,含1和9
            inc = random.randrange(1, 10)
            # (d+inc)%10一定不是d,用来保证负样本对的图片绝不会来自同一个类
            dn = (d + inc) % 10
            # 在d类和dn类中分别取i样本构成负样本对
            z1, z2 = digit_indices[d][i], digit_indices[dn][i]
            pairs += [[x[z1], x[z2]]]
            # 添加正负样本标签
            labels += [1, 0]
return np.array(pairs), np.array(labels)

这部分是纯Python代码,计算过程见注释,比较诡异的是这行代码:

n = min([len(digit_indices[d]) for d in range(10)]) - 1

这里n是所有类别的样本数目的最小值再减1,注意到n在循环体中作为循环范围:

    for d in range(10):
        for i in range(n):
            z1, z2 = digit_indices[d][i], digit_indices[d][i+1]

将n设置为所有类别样本数目之最小值,可以保证对所有类别而言,生成的正样本数目和负样本数目都是一样的,从而保证整个训练集的类别均衡。-1是因为在循环中需要访问[i+1],这是为了保证不超出范围。

组织样本的写法有多种多样,生成的样本数目也有所不同。你完全可以编写一种能生成出更多样本的方式,例如正样本不仅仅取相邻的[i]和[i+1],而是遍历所有的组合可能性,这些完全可以按照实际需求编写。

训练集和测试集的政府样本对均照此生成:

digit_indices = [np.where(y_train == i)[0] for i in range(10)]
tr_pairs, tr_y = create_pairs(X_train, digit_indices)

digit_indices = [np.where(y_test == i)[0] for i in range(10)]
te_pairs, te_y = create_pairs(X_test, digit_indices)

代码的最后是对整个网络的训练,相比较而言,这部分已经不是什么值得谈的东西了,简单放在下面就好:

rms = RMSprop()
model.compile(loss=contrastive_loss, optimizer=rms)
model.fit([tr_pairs[:, 0], tr_pairs[:, 1]], tr_y,
          validation_data=([te_pairs[:, 0], te_pairs[:, 1]], te_y),
          batch_size=128,
          nb_epoch=nb_epoch)

# compute final accuracy on training and test sets
pred = model.predict([tr_pairs[:, 0], tr_pairs[:, 1]])
tr_acc = compute_accuracy(pred, tr_y)
pred = model.predict([te_pairs[:, 0], te_pairs[:, 1]])
te_acc = compute_accuracy(pred, te_y)

print('* Accuracy on training set: %0.2f%%' % (100 * tr_acc))
print('* Accuracy on test set: %0.2f%%' % (100 * te_acc))

哦,这里的准确率是自己算的,因为这种网络的准确率keras好像没有定义过,需要一个阈值来判断最终预测结果是正例还是负例,非常简单,贴一下:

def compute_accuracy(predictions, labels):
    '''Compute classification accuracy with a fixed threshold on distances.
    '''
return labels[predictions.ravel() < 0.5].mean()

全部代码请参考keras examples里的mnist_siamese_graph.py,噫,看后缀有个_graph我怀疑这个例子在上古时代(Keras还有Graph这个类型的时候)就有了,也算缅怀一下那逝去的旧时光吧~

感谢阅读,我真的要过好久才更下一篇啦!

编辑于 2017-03-23

文章被以下专栏收录