admin管理员组文章数量:1561812
层的自定义
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中自定义层的时候,重用已有的层,这将大大减少自定义层的代码量,自定义层的基本方法,其核心步骤是定义build
和call
两个函数,其中build
负责创建可训练的权重,而call则定义具体的运算。
经常用到自定义层的读者可能会感觉到,在自定义层的时候我们经常在重复劳动,比如我们想要增加一个线性变换,那就要在build
中增加一个kernel
和bias
变量(还要自定义变量的初始化、正则化等),然后在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(q∣p)=−i∑qilogpi
其中
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=1∑n31log(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=−logi∑eWi⊤x+bieWy⊤x+by+λ∥∥∥x−cy∥∥∥2
其中 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(x⊗m)
这里的
⊗
\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−(1−m)×1010)
正常来说,神经网络的输入输出的数量级不会很大,所以经过
x
−
(
1
−
m
)
×
1
0
10
x−(1−m)×10^{10}
x−(1−m)×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−(1−m)×1010)
上面几个算子的mask处理算是比较特殊的,其余运算的mask处理(除了双向RNN),基本上只需要输出
x
⊗
m
x\otimes m
x⊗m
就行了,也就是让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更酷一些
版权声明:本文标题:keras之自定义层和部分loss 内容由热心网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:https://m.elefans.com/dianzi/1727467688a1115926.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论