用 Keras 实现验证网络 siamese

keras示例程序解析

Posted by bigmoyan on October 6, 2017

keras示例程序解析

转载自《【啄米日常】6:keras示例程序解析(3)验证网络siamese》,作者 bigmoyan。版权归原作者所有,部分内容有删减。

阅读本文你将学到:

  • 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 作为特征提取的网络,所以整个网络的图是:

1

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

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

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

这是结构,再说训练。

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

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

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

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

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

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

看着麻烦,其实不麻烦。$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 的 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)

将 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 这个类型的时候)就有了,也算缅怀一下那逝去的旧时光吧~

转载自《【啄米日常】6:keras示例程序解析(3)验证网络siamese》,作者 bigmoyan。版权归原作者所有,部分内容有删减。