keras之自定义层和部分loss

层的自定义

Keras中自定义层及其一些运用技巧,在这之中我们可以看到Keras层的精巧之处。

基本定义方法

在Keras中,自定义层的最简单方法是通过Lambda层的方式:

from keras.layers import *
from keras import backend as K

x_in = Input(shape=(10,))
x = lambda(lambda x:x+2)(x_in) # 对输入加上2

有时候,我们希望区分训练阶段和测试阶段,比如训练阶段给输入加入一些噪声,而测试阶段则去掉噪声,这需要用K.in_train_phase实现,比如

def add_noise_in_train(x):
	x_ = x + K.random_normal(shape = K.shap(x))# 加上标准高斯噪声
	return K.in_trian_phase(x_,x)

x_in = Input(shape=(10,))
x = Lambda(add_noise_in_train)(x_in) # 训练阶段加入高斯噪声,测试阶段去掉

当然,Lambda层仅仅适用于不需要增加训练参数的情形,如果想要实现的功能需要往模型新增参数,那么就必须要用到自定义Layer了。其实这也不复杂,相比于Lambda层只不过代码多了几行,官方文章已经写得很清楚了:
https://keras.io/layers/writing-your-own-keras-layers/

class MyLayer(Layer):
	def __init__(self,output_dim,**kwargs):
		self.output_dim = output_dim # 可以自定义一些属性,方便调用
		super(MyLayer,self).__init__(**kwargs) # 必须
	def build(self,input_shape):
		# 添加可训练参数
		self.kernel = self.add_weight(name='kernel',
								  shape=(input_shape[1],self.output_dim),
								  initializer='uniform',
								  trainable=True)
	def call(self,x):
		# 定义功能,相当于Lambda层的功能函数
        return K.dot(x, self.kernel)
    def compute_output_shape(self,input_shape):
    	# 计算输出形状,如果输入和输出形状一致,那么可以省略,否则最好加上
    	return (input_shape[0], self.output_dim)

双输出的层

平时我们碰到的所有层,几乎都是单输出的,包括Keras中自带的所有层,都是一个或者多个输入,然后返回一个结果输出的。那么Keras可不可以定义双输出的层呢?答案是可以,但要明确定义好output_shape,比如下面这个层,简单地将输入切开分两半,并且同时返回。

class SplitVector(Layer):
	def __init__(self,**kwargs):
		super(SplitVector,self).__init__(**kwargs)
	def call(self,inputs):
		# 按第二个维度对tensor进行切片,返回一个list
		in_dim = K.int_shape(inputs)[-1]
		return [inputs[:,:in_dim//2],inputs[:,in_dim//2:]]
	def compute_output_shape(self,input_shape):
		# output_shape也要是对应的list
		in_dim = input_shape[-1]
		return [(None,in_dim//2),(None,in_dim-in_dim//2)]
x1,x2 = SplitVector()(x_in)

层中层

在Keras中自定义层的时候,重用已有的层,这将大大减少自定义层的代码量,自定义层的基本方法,其核心步骤是定义buildcall两个函数,其中build负责创建可训练的权重,而call则定义具体的运算。
经常用到自定义层的读者可能会感觉到,在自定义层的时候我们经常在重复劳动,比如我们想要增加一个线性变换,那就要在build中增加一个kernelbias变量(还要自定义变量的初始化、正则化等),然后在call里边用K.dot来执行,有时候还需要考虑维度对齐的问题,步骤比较繁琐。但事实上,一个线性变换其实就是一个不加激活函数的Dense层罢了,如果在自定义层时能重用已有的层,那显然就可以大大节省代码量了。

OurLayer

首先,我们定义一个新的OurLayer类:

class OurLayer(Layer):
	'''定义新的Layer,增加reuse方法,允许在定义Layer时调用现成的层
	'''
	def reuse(self,layer,*args,**kwargs):#星号*把序列/集合解包(unpack)成位置参数,两个星号**把字典解包成关键字参数。
		if not layer.built:
			if len(args)>0:
				inputs = args[0]
			else:
				inputs = kwargs['inputs']
			if isinstance(inputs,list): #isinstance() 函数来判断一个对象是否是一个已知的类型,类似 type()。
				input_shape = [K.int_shape(x) for x in inputs]
			else:
				input_shape = K.int_shape(inputs)
			layer.build(input_shape)
		outputs = layer.call(*args, **kwargs)
		for w in layer.trainable_weights:
			if w not in self._trainable_weights:
				self._trainable_weights.append(w)
		for w in layer.non_trainable_weights:
            if w not in self._non_trainable_weights:
                self._non_trainable_weights.append(w)
         return outputs

这个OurLayer类继承了原来的Layer类,为它增加了reuse方法,就是通过它我们可以重用已有的层。
下面是一个简单的例子,定义一个层,运算如下
y = g ( f ( x W 1 + b 1 ) W 2 + b 2 ) y = g(f(xW_1 + b_1)W_2 + b_2) y=g(f(xW1+b1)W2+b2)
这里f,g是激活函数,其实就是两个Dense层的复合,如果按照标准的写法,我们需要在build那里定义好几个权重,定义权重的时候还需要根据输入来定义shape,还要定义初始化等,步骤很多,但事实上这些在Dense层不都写好了吗,直接调用就可以了,参考调用代码如下:

class OurDense(OurLayer):
    """原来是继承Layer类,现在继承OurLayer类
    """
    def __init__(self,hidden_dimdim,output_dim,
    			 hidden_activation='linear',
    			 output_activation='linear', **kwargs):
    	super(OurDense,self).__init__(**kwargs)
    	self.hidden_dim = hidden_dim
        self.output_dim = output_dim
        self.hidden_activation = hidden_activation
        self.output_activation = output_activation
	def build(self,input_shape):
		 """在build方法里边添加需要重用的层,
        当然也可以像标准写法一样条件可训练的权重。
        """
        super(OurDense, self).build(input_shape)
        self.h_dense = Dense(self.hidden_dim,
                             activation=self.hidden_activation)
        self.o_dense = Dense(self.output_dim,
                             activation=self.output_activation)
                             def call(self, inputs):
        """直接reuse一下层,等价于o_dense(h_dense(inputs))
        """
        h = self.reuse(self.h_dense, inputs)
        o = self.reuse(self.o_dense, h)
        return o
    def compute_output_shape(self, input_shape):
        return input_shape[:-1] + (self.output_dim,)

自定义loss

Keras的模型是函数式的,即有输入,也有输出,而loss即为预测值与真实值的某种误差函数。Keras本身也自带了很多loss函数,如mse、交叉熵等,直接调用即可。而要自定义loss,最自然的方法就是仿照Keras自带的loss进行改写。
比如,我们做分类问题时,经常用的就是softmax输出,然后用交叉熵作为loss。然而这种做法也有不少缺点,其中之一就是分类太自信,哪怕输入噪音,分类的结果也几乎是非1即0,这通常会导致过拟合的风险,还会使得我们在实际应用中没法很好地确定置信区间、设置阈值。因此很多时候我们也会想办法使得分类别太自信,而修改loss也是手段之一。
如果不修改loss,我们就是使用交叉熵去拟合一个one hot的分布。交叉熵的公式是
S ( q ∣ p ) = − ∑ i q i log ⁡ p i S(q|p)=-\sum_i q_i \log p_i S(qp)=iqilogpi
其中 p i p_i pi是预测的分布,而 q i q_i qi是真实的分布,比如输出为 [ z 1 , z 2 , z 3 ] [z_1,z_2,z_3] [z1,z2,z3],目标为 [ 1 , 0 , 0 ] [1,0,0] [1,0,0],那么
l o s s = − log ⁡ ( e z 1 / Z ) ,   Z = e z 1 + e z 2 + e z 3 loss = -\log \Big(e^{z_1}/Z\Big),\, Z=e^{z_1}+e^{z_2}+e^{z_3} loss=log(ez1/Z),Z=ez1+ez2+ez3
只要 z 1 z_1 z1已经是 [ z 1 , z 2 , z 3 ] [z_1,z_2,z_3] [z1,z2,z3]的最大值,那么我们总可以“变本加厉”——通过增大训练参数,使得 [ z 1 , z 2 , z 3 ] [z_1,z_2,z_3] [z1,z2,z3]增加足够大的比例(等价地,即增大向量 [ z 1 , z 2 , z 3 ] [z_1,z_2,z_3] [z1,z2,z3]的模长),从而 e z 1 / Z e^{z_1}/Z ez1/Z足够接近1(等价地,loss足够接近0)。这就是通常softmax过于自信的来源:只要盲目增大模长,就可以降低loss,训练器肯定是很乐意了,这代价太低了。为了使得分类不至于太自信,一个方案就是不要单纯地去拟合one hot分布,分一点力气去拟合一下均匀分布,即改为新loss:
l o s s = − ( 1 − ε ) log ⁡ ( e z 1 / Z ) − ε ∑ i = 1 n 1 3 log ⁡ ( e z i / Z ) ,   Z = e z 1 + e z 2 + e z 3 loss = -(1-\varepsilon)\log \Big(e^{z_1}/Z\Big)-\varepsilon\sum_{i=1}^n \frac{1}{3}\log \Big(e^{z_i}/Z\Big),\, Z=e^{z_1}+e^{z_2}+e^{z_3} loss=(1ε)log(ez1/Z)εi=1n31log(ezi/Z),Z=ez1+ez2+ez3
这样,盲目地增大比例使得 e z 1 / Z e^{z_1}/Z ez1/Z接近于1,就不再是最优解了,从而可以缓解softmax过于自信的情况,不少情况下,这种策略还可以增加测试准确率(防止过拟合)。
那么,在Keras中应该怎么写呢?

from keras.layers import Input,Embedding,LSTM,Dense
from keras.models import Model
from keras import backend as K

word_size = 128
nb_features = 10000
nb_classes = 10
encode_size = 64

input = Input(shape=(None,))
embedded = Embedding(nb_features,word_size)(input)
encoder = LSTM(encode_size)(embedded)
predict = Dense(nb_classes,activation='softmax')(encoder)

def mycrossentropy(y_true,t_pred,e=0.1):
	loss1 = K.categorical_crossentropy(y_true,y_pred)
	loss2 = K.categorical_crossentropy(K.ones_like(pred)/nb_classes,y_pred)
	return (1-e)*loss1 + e*loss2

也就是自定义一个输入为y_pred,y_true的loss函数,放进模型compile即可。这里的mycrossentropy,第一项就是普通的交叉熵,第二项中,先通过K.ones_like(y_pred)/nb_classes构造了一个均匀分布,然后算y_pred与均匀分布的交叉熵。

并不仅仅是输入输出那么简单

前面已经说了,Keras的模型有固定的输入和输出,并且loss即为预测值与真实值的某种误差函数,然而,很多模型并非这样的,比如问答模型与triplet loss。
这个的问题是指有固定的答案库的FAQ形式的问答。一种常见的做问答模型的方法就是:先分别将答案和问题都encode成为一个同样长度的向量,然后比较它们的\cos值,\cos越大就越匹配。这种做法很容易理解,是一个比较通用的框架,比如这里的问题和答案都不需要一定是问题,图片也行,反正只不过是encode的方法不一样,最终只要能encode出一个向量来即可。但是怎么训练呢?我们当然希望正确答案的\cos值越大越好,错误答案的\cos值越小越好,但是这不是必要的,合理的要求应该是:正确答案的\cos值比所有错误答案的\cos值都要大,大多少无所谓,一丁点都行。因此,这就导致了triplet loss:
l o s s = max ⁡ ( 0 , m + cos ⁡ ( q , A wrong ) − cos ⁡ ( q , A right ) ) loss = \max\Big(0, m+\cos(q,A_{\text{wrong}})-\cos(q,A_{\text{right}})\Big) loss=max(0,m+cos(q,Awrong)cos(q,Aright))
其中 m m m是一个大于零的正数。
怎么理解这个loss呢?要注意我们要最小化loss,所以只看 m + cos ⁡ ( q , A wrong ) − cos ⁡ ( q , A right ) m+\cos(q,A_{\text{wrong}})-\cos(q,A_{\text{right}}) m+cos(q,Awrong)cos(q,Aright)这部分,我们知道目的是拉大正确与错误答案的差距,但是,一旦 cos ⁡ ( q , A right ) − cos ⁡ ( q , A wrong ) > m \cos(q,A_{\text{right}})-\cos(q,A_{\text{wrong}}) > m cos(q,Aright)cos(q,Awrong)>m,也就是差距大于 m m m,由于 m a x max max的存在,loss就等于0,这时候就自动达到最小值,就不会优化它了。所以,triplet loss的思想就是:只希望正确比错误答案的差距大一点(并不是越大越好),超过 m m m就别管它了,集中精力关心那些还没有拉开的样本吧!
我们已经有问题和正确答案,错误答案只要随机挑就行,所以这样训练样本是很容易构造的。不过Keras中怎么实现triplet loss呢?看上去是一个单输入、双输出的模型,但并不是那么简单,Keras中的双输出模型,只能给每个输出分别设置一个loss,然后加权求和,但这里不能简单表示成两项的加权求和。那应该要怎么搭建这样的模型呢?下面是一个例子:


from keras.layers import Input,Embedding,LSTM,Dense,Lambda
from keras.layers.merge import dot
from keras.models import Model
from keras import backend as K

word_size = 128
nb_features = 10000
nb_classes = 10
encode_size = 64
margin = 0.1
embedding = Embedding(nb_features,word_size)
lstm_encoder = LSTM(encode_size)

def encode(input):
    return lstm_encoder(embedding(input))

q_input = Input(shape=(None,))
a_right = Input(shape=(None,))
a_wrong = Input(shape=(None,))
q_encoded = encode(q_input)
a_right_encoded = encode(a_right)
a_wrong_encoded = encode(a_wrong)
q_encoded = Dense(encode_size)(q_encoded) #一般的做法是,直接将问题和答案用同样的方法encode成向量后直接匹配,但我认为这是不合理的,我认为至少经过某个变换。
right_cos = dot([q_encoded,a_right_encoded], -1, normalize=True)
wrong_cos = dot([q_encoded,a_wrong_encoded], -1, normalize=True)
loss = Lambda(lambda x: K.relu(margin+x[0]-x[1]))([wrong_cos,right_cos])

model_train = Model(inputs=[q_input,a_right,a_wrong], outputs=loss)
model_q_encoder = Model(inputs=q_input, outputs=q_encoded)
model_a_encoder = Model(inputs=a_right, outputs=a_right_encoded)

model_train.compile(optimizer='adam', loss=lambda y_true,y_pred: y_pred)
model_q_encoder.compile(optimizer='adam', loss='mse')
model_a_encoder.compile(optimizer='adam', loss='mse')

model_train.fit([q,a1,a2], y, epochs=10)
#其中q,a1,a2分别是问题、正确答案、错误答案的batch,y是任意形状为(len(q),1)的矩阵

如果第一次看不懂,那么请反复阅读几次,这个代码包含了Keras中实现最一般模型的思路:把目标当成一个输入,构成多输入模型,把loss写成一个层,作为最后的输出,搭建模型的时候,就只需要将模型的output定义为loss,而compile的时候,直接将loss设置为y_pred(因为模型的输出就是loss,所以y_pred就是loss),无视y_true,训练的时候,y_true随便扔一个符合形状的数组进去就行了。最后我们得到的是问题和答案的编码器,也就是问题和答案都分别编码出一个向量来,我们只需要比较\cos,就可以选择最优答案了

扩展:Embedding层的妙用之centerloss

对于Embedding层,可以有两种理解:

  • 是one hot输入的全连接层的加速版本,也就是说,它就是一个以one hot为输入的Dense层,数学上完全等价;
  • 它就是一个矩阵查找操作,输入一个整数,输出对应下标的向量,只不过这个矩阵是可训练的。
    这部分我们来关心center loss。前面已经说了,做分类时,一般是softmax+交叉熵做,用矩阵的写法,softmax就是
    softmax ( W x + b ) \text{softmax}\Big(\boldsymbol{W}\boldsymbol{x}+\boldsymbol{b}\Big) softmax(Wx+b)
    其中 x x x可以理解为提取的特征,而 W , b W,b W,b是最后的全连接层的权重,整个模型是一起训练的。问题是,这样的方案所训练出来的特征模型 x x x,具有怎样的形态呢?
    有一些情况下,我们更关心特征 x x x而不是最后的分类结果,比如人脸识别场景,假如我们有10万个不同的人的人脸数据库,每个人有若干张照片,那么我们就可以训练一个10万分类模型,对于给定的照片,我们可以判断它是10万个中的哪一个。但这仅仅是训练场景,那么怎么应用呢?到了具体的应用环境,比如一个公司内部,可能有只有几百人;在公共安全检测场景,可能有数百万人,所以前面做好的10万分类模型基本上是没有意义的,但是在这个模型softmax之前的特征,也就是前一段所说的 x x x,可能还是很有意义的。如果对于同一个人(也就是同一类),x基本一样,那么实际应用中,我们就可以把训练好的模型当作特征提取工具,然后把提取出来的特征直接用KNN(最邻近距离)来做就行了。
    设想很美好,但事实很残酷,直接训练softmax的话,事实上得到的特征不一定具有聚类特性,相反,它们会尽量布满整个空间(没有给其他人留出位置,参考center loss的相关论文和文章,比如这篇。)。那么,怎样训练才使得结果有聚类特性呢?center loss使用了一种简单粗暴但是却很有效的方案——加聚类惩罚项。完整地写出来,就是
    l o s s = − log ⁡ e W y ⊤ x + b y ∑ i e W i ⊤ x + b i + λ ∥ x − c y ∥ 2 loss = - \log\frac{e^{\boldsymbol{W}_y^{\top}\boldsymbol{x}+b_y}}{\sum\limits_i e^{\boldsymbol{W}_i^{\top}\boldsymbol{x}+b_i}} + \lambda \Big\Vert \boldsymbol{x}-\boldsymbol{c}_y \Big\Vert^2 loss=logieWix+bieWyx+by+λxcy2
    其中 y y y对应着正确的类别。可以看到,第一项就是普通的softmax交叉熵,第二项就是额外的惩罚项,它给每个类定义了可训练的中心c,要求每个类要跟各自的中心靠得很近。所以,总的来说,第一项负责拉开不同类之间的距离,第二项负责缩小同一类之间的距离。
    那么,Keras中要怎么实现这个方案?关键是,怎么存放聚类中心?答案就是Embedding层!这部分的开头已经提示了,Embedding就是一个待训练的矩阵罢了,正好可以存放聚类中心参数。于是,模仿第二部分的写法,就得到
from keras.layers import Input,Conv2D, MaxPooling2D,Flatten,Dense,Embedding,Lambda
from keras.models import Model
nb_classes = 100
feature_size = 32
input_image = Input(shape=(224,224,3))
cnn = Conv2D(10, (2,2))(input_image)
cnn = MaxPooling2D((2,2))(cnn)
cnn = Flatten()(cnn)
feature = Dense(feature_size, activation='relu')(cnn)
predict = Dense(nb_classes, activation='softmax', name='softmax')(feature) #至此,得到一个常规的softmax分类模型

input_target = Input(shape=(1,))
centers = Embedding(nb_classes, feature_size)(input_target) #Embedding层用来存放中心
l2_loss = Lambda(lambda x: K.sum(K.square(x[0]-x[1][:,0]), 1, keepdims=True), name='l2_loss')([feature,centers])# a=[[1,2],[3,4]] >> np.sum(a,1)>> [3,7]

model_train = Model(inputs=[input_image,input_target], outputs=[predict,l2_loss])
model_train.compile(optimizer='adam', loss=['sparse_categorical_crossentropy',lambda y_true,y_pred: y_pred], loss_weights=[1.,0.2], metrics={'softmax':'accuracy'})

model_predict = Model(inputs=input_image, outputs=predict)
model_predict.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

model_train.fit([train_images,train_targets], [train_targets,random_y], epochs=10)
#TIPS:这里用的是sparse交叉熵,这样我们直接输入整数的类别编号作为目标,而不用转成one hot形式。所以Embedding层的输入,跟softmax的目标,都是train_targets,都是类别编号,而random_y是任意形状为(len(train_images),1)的矩阵。

读者可能有疑问,为什么不像第二部分的triplet loss模型那样,将整体的loss写成一个单一的输出,然后搭建模型,而是要像目前这样变成双输出呢?
事实上,Keras爱好者钟情于Keras,其中一个很重要的原因就是它的进度条——能够实时显示训练loss、训练准确率。如果像第二部分那样写,那么就不能设置metrics参数,那么训练过程中就不能显示准确率了,这不能说是一个小遗憾。而目前这样写,我们就依然能够在训练过程中看到训练准确率,还能分别看到交叉熵loss、l2_loss、总的loss分别是多少,非常舒服

model_train.summary()
_________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to
==================================================================================================
input_1 (InputLayer)            (None, 224, 224, 3)  0
__________________________________________________________________________________________________
conv2d_1 (Conv2D)               (None, 223, 223, 10) 130         input_1[0][0]
__________________________________________________________________________________________________
max_pooling2d_1 (MaxPooling2D)  (None, 111, 111, 10) 0           conv2d_1[0][0]
__________________________________________________________________________________________________
flatten_1 (Flatten)             (None, 123210)       0           max_pooling2d_1[0][0]
__________________________________________________________________________________________________
input_2 (InputLayer)            (None, 1)            0
__________________________________________________________________________________________________
dense_1 (Dense)                 (None, 32)           3942752     flatten_1[0][0]
__________________________________________________________________________________________________
embedding_1 (Embedding)         (None, 1, 32)        3200        input_2[0][0]
__________________________________________________________________________________________________
softmax (Dense)                 (None, 100)          3300        dense_1[0][0]
__________________________________________________________________________________________________
l2_loss (Lambda)                (None, 1)            0           dense_1[0][0]
                                                                 embedding_1[0][0]
==================================================================================================
Total params: 3,949,382
Trainable params: 3,949,382
Non-trainable params: 0
__________________________________________________________________________________________________
model_predict.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_1 (InputLayer)         (None, 224, 224, 3)       0
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 223, 223, 10)      130
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 111, 111, 10)      0
_________________________________________________________________
flatten_1 (Flatten)          (None, 123210)            0
_________________________________________________________________
dense_1 (Dense)              (None, 32)                3942752
_________________________________________________________________
softmax (Dense)              (None, 100)               3300
=================================================================
Total params: 3,946,182
Trainable params: 3,946,182
Non-trainable params: 0

扩展:层与loss的结合

Keras中对loss的基本定义是一个输入为y_true和y_pred函数。但在比较复杂的情况下,它不仅仅是预测值和目标值的函数,还可以结合权重进行复杂的运算。

这里再次以center loss为例,介绍一种基于自定义层的写法。

class Dense_with_Center_loss(Layer):
	def __init__(self,output_dim,**kwargs):
		self.output_dim = output_dim
		super(Dense_with_Center_loss, self).__init__(**kwargs)
	def build(self, input_shape):
		 # 添加可训练参数
        self.kernel = self.add_weight(name='kernel',
                                      shape=(input_shape[1], self.output_dim),
                                      initializer='glorot_normal',
                                      trainable=True)
        self.bias = self.add_weight(name='bias',
                                    shape=(self.output_dim,),
                                    initializer='zeros',
                                    trainable=True)
        self.centers = self.add_weight(name='centers',
                                       shape=(self.output_dim, input_shape[1]),
                                       initializer='glorot_normal',
                                       trainable=True)
	def call(self, inputs):
        # 对于center loss来说,返回结果还是跟Dense的返回结果一致
        # 所以还是普通的矩阵乘法加上偏置
        self.inputs = inputs
        return K.dot(inputs, self.kernel) + self.bias
    def compute_output_shape(self, input_shape):
        return (input_shape[0], self.output_dim)
    def loss(self, y_true, y_pred, lamb=0.5):
        # 定义完整的loss
        y_true = K.cast(y_true, 'int32') # 保证y_true的dtype为int32
        crossentropy = K.sparse_categorical_crossentropy(y_true, y_pred, from_logits=True)
        centers = K.gather(self.centers, y_true[:, 0]) # 取出样本中心
        center_loss = K.sum(K.square(centers - self.inputs), axis=1) # 计算center loss
        return crossentropy + lamb * center_loss
f_size = 2

x_in = Input(shape=(784,))
f = Dense(f_size)(x_in)

dense_center = Dense_with_Center_loss(10)
output = dense_center(f)

model = Model(x_in, output)
model.compile(loss=dense_center.loss,
              optimizer='adam',
              metrics=['sparse_categorical_accuracy'])

# 这里是y_train是类别的整数id,不用转为one hot
model.fit(x_train, y_train, epochs=10)

扩展:NLP中的Mask

排除padding

mask是伴随这padding出现的,因为神经网络的输入需要一个规整的张量,而文本通常都是不定长的,这样一来就需要裁剪或者填充的方式来使得它们变成定长,按照常规习惯,我们会使用0作为padding符号。

这里用简单的向量来描述padding的原理。假设有一个长度为5的向量:
x = [ 1 , 0 , 3 , 4 , 5 ] x = [1, 0, 3, 4, 5] x=[1,0,3,4,5]
经过padding变成长度为8:
x = [ 1 , 0 , 3 , 4 , 5 , 0 , 0 , 0 ] x = [1, 0, 3, 4, 5, 0, 0, 0] x=[1,0,3,4,5,0,0,0]
当你将这个长度为8的向量输入到模型中时,模型并不知道你这个向量究竟是“长度为8的向量”还是“长度为5的向量,填充了3个无意义的0”。为了表示出哪些是有意义的,哪些是padding的,我们还需要一个mask向量(矩阵):
m = [ 1 , 1 , 1 , 1 , 1 , 0 , 0 , 0 ] m = [1, 1, 1, 1, 1, 0, 0, 0] m=[1,1,1,1,1,0,0,0]
这是一个0/1向量(矩阵),用1表示有意义的部分,用0表示无意义的padding部分。
所谓mask,就是 x x x m m m的运算,来排除padding带来的效应。比如我们要求 x x x的均值,本来期望的结果是:
avg ( x ) = 1 + 0 + 3 + 4 + 5 5 = 2.6 \text{avg}(x) = \frac{1 + 0 + 3 + 4 + 5}{5} = 2.6 avg(x)=51+0+3+4+5=2.6
但是由于向量已经经过padding,直接算的话就得到:
1 + 0 + 3 + 4 + 5 + 0 + 0 + 0 8 = 1.625 \frac{1 + 0 + 3 + 4 + 5 + 0 + 0 + 0}{8} = 1.625 81+0+3+4+5+0+0+0=1.625
会带来偏差。更严重的是,对于同一个输入,每次padding的零的数目可能是不固定的,因此同一个样本每次可能得到不同的均值,这是很不合理的。有了mask向量m之后,我们可以重写求均值的运算:
avg ( x ) = sum ( x ⊗ m ) sum ( m ) \text{avg}(x) = \frac{\text{sum}(x\otimes m)}{\text{sum}(m)} avg(x)=sum(m)sum(xm)
这里的 ⊗ \otimes 是逐位对应相乘的意思。这样一来,分子只对非padding部分求和,分母则是对非padding部分计数,不管你padding多少个零,最终算出来的结果都是一样的。
如果要求 x x x的最大值呢?我们有 max ⁡ ( [ 1 , 0 , 3 , 4 , 5 ] ) = max ⁡ ( [ 1 , 0 , 3 , 4 , 5 , 0 , 0 , 0 ] ) = 5 \max([1, 0, 3, 4, 5]) = \max([1, 0, 3, 4, 5, 0, 0, 0]) = 5 max([1,0,3,4,5])=max([1,0,3,4,5,0,0,0])=5,似乎不用排除padding效应了?在这个例子中是这样,但还有可能是:
x = [ − 1 , − 2 , − 3 , − 4 , − 5 ] x = [-1, -2, -3, -4, -5] x=[1,2,3,4,5]
经过padding后变成了
x = [ − 1 , − 2 , − 3 , − 4 , − 5 , 0 , 0 , 0 ] x = [-1, -2, -3, -4, -5, 0, 0, 0] x=[1,2,3,4,5,0,0,0]
如果直接对padding后的 x x x m a x max max,那么得到的是0,而0不在原来的范围内。这时候解决的方法是:让padding部分足够小,以至于 m a x max max(几乎)不能取到padding部分,比如
max ⁡ ( x ) = max ⁡ ( x − ( 1 − m ) × 1 0 10 ) \max(x) = \max\left(x - (1 - m) \times 10^{10}\right) max(x)=max(x(1m)×1010)
正常来说,神经网络的输入输出的数量级不会很大,所以经过 x − ( 1 − m ) × 1 0 10 x−(1−m)×10^{10} x(1m)×1010后,padding部分在 − 1 0 10 −10^{10} 1010这个数量级中上,可以保证取 m a x max max的话不会取到padding部分了。
处理softmax的padding也是如此。在Attention或者指针网络时,我们就有可能遇到对变长的向量做softmax,如果直接对padding后的向量做softmax,那么padding部分也会平摊一部分概率,导致实际有意义的部分概率之和都不等于1了。解决办法跟 m a x max max时一样,让padding部分足够小足够小,使得 e x e^x ex足够接近于0,以至于可以忽略:
sofmax ( x ) = softmax ( x − ( 1 − m ) × 1 0 10 ) \text{sofmax}(x) = \text{softmax}\left(x - (1 - m) \times 10^{10}\right) sofmax(x)=softmax(x(1m)×1010)
上面几个算子的mask处理算是比较特殊的,其余运算的mask处理(除了双向RNN),基本上只需要输出
x ⊗ m x\otimes m xm
就行了,也就是让padding部分保持为0。
Keras自带了mask功能,但是不建议用,因为自带的mask不够清晰灵活,而且也不支持所有的层,强烈建议读者自己实现mask。
一般来说NLP模型的输入是词ID矩阵,形状为[batch_size, seq_len],其中我会用0作为padding的ID,而1作为UNK的ID,剩下的就随意了,然后我就用一个Lambda层生成mask矩阵:

# x是词ID矩阵
mask = Lambda(lambda x: K.cast(K.greater(K.expand_dims(x, 2), 0), 'float32'))(x)

这样生成的mask矩阵大小是[batch_size, seq_len, 1],然后词ID矩阵经过Embedding层后的大小为[batch_size, seq_len, word_size],这样一来就可以用mask矩阵对输出结果就行处理了。

结合:双向RNN

刚才我们的讨论排除了双向RNN,这是因为RNN是递归模型,没办法简单地mask(主要是逆向RNN这部分)。所谓双向RNN,就是正反各做一次RNN然后拼接或者相加之类的。假如我们要对[1,0,3,4,5,0,0,0]做逆向RNN运算时,最后输出的结果都会包含padding部分的0(因为padding部分在一开始就参与了运算)。因此事后是没法排除的,只有在事前排除。

排除的方案是:要做逆向RNN,先将[1,0,3,4,5,0,0,0]反转为[5,4,3,0,1,0,0,0],然后做一个正向RNN,然后再把结果反转回去,要注意反转的时候只反转非padding部分(这样才能保证递归运算时padding部分始终不参与,并且保证跟正向RNN的结果对齐),这个tensorflow提供了现成的函数tf.reverse_sequence()

遗憾的是,Keras自带的Bidirectional并没有这个功能,所以我重写了它,供读者参考:

class OurBidirectional(OurLayer):
    """自己封装双向RNN,允许传入mask,保证对齐
    """
    def __init__(self, layer, **args):
        super(OurBidirectional, self).__init__(**args)
        self.forward_layer = copy.deepcopy(layer)
        self.backward_layer = copy.deepcopy(layer)
        self.forward_layer.name = 'forward_' + self.forward_layer.name
        self.backward_layer.name = 'backward_' + self.backward_layer.name
    def reverse_sequence(self, x, mask):
        """这里的mask.shape是[batch_size, seq_len, 1]
        """
        seq_len = K.round(K.sum(mask, 1)[:, 0])
        seq_len = K.cast(seq_len, 'int32')
        return K.tf.reverse_sequence(x, seq_len, seq_dim=1)
    def call(self, inputs):
        x, mask = inputs
        x_forward = self.reuse(self.forward_layer, x)
        x_backward = self.reverse_sequence(x, mask)
        x_backward = self.reuse(self.backward_layer, x_backward)
        x_backward = self.reverse_sequence(x_backward, mask)
        x = K.concatenate([x_forward, x_backward], 2)
        return x * mask
    def compute_output_shape(self, input_shape):
        return (None, input_shape[0][1], self.forward_layer.units * 2)

使用方法跟自带的Bidirectional基本一样的,只不过要多传入mask矩阵,比如:

x = OurBidirectional(LSTM(128))([x, x_mask])
参考来源:苏神:让keras更酷一些

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值