梯度裁剪
之前的文章向大家介绍了解决梯度问题的一个常用方法,批标准化。今天我们来一个能够减轻梯度爆炸问题的手段,梯度裁剪。所谓梯度裁剪,是在反向传播的过程中,对梯度进行修剪,使得梯度永远不会达到某个阈值。这个技术通常用在循环神经网络中,因为批标准化在RNN中不太易用。但是在其他类型的神经网络中,批标准化总是表现得非常出色。
在Keras里,实现梯度裁剪只需要在创建优化器时,设置clipvalue或者clipnorm参数就好了,就像这样:
optimizer = keras.optimizers.SGD(clipvalue=1.0)
model.compile(loss="mse", optimizer=optimizer)
这个设置好的优化器会将梯度向量的每个值裁剪至-1.0到1.0区间内,也就是说所有损失函数的偏导数(对于每个可训练参数)被裁剪至-1.0到1.0之间。其中clipvalue参数,作为阈值,是可以调的超参,但是要注意这可能会改变梯度向量的方向。比如说,如果原梯度向量是[0.9, 100.0],那么它的方向基本上就指向第二个轴。但是当你裁剪了梯度,那么梯度就变成了[0.9, 1.0],裁剪后的向量基本上就指向两个轴之间。在实际操作当中,这个方法的结果不错。但是如果你希望保证梯度裁剪法不改变梯度向量的方向,那么就不能设置clipvalue参数,而需要设置clipnorm参数。这个方法会将l2范数大于阈值的梯度进行裁剪。比如我们设置clipnorm = 1.0,那么向量[0.9, 100.0]就会被裁剪为[0.0089964, 0.9999595],保持其方向不变,但是第一个值几乎要被消除了。如果我们在训练过程中观察到了梯度爆炸的现象(可以用TensorBoard进行梯度追踪),那么就可以试着用不同阈值的按值裁剪和按方向裁剪,然后看哪个方案在验证集上的表现更好。
预训练层重用
一般来说,从头开始训练一个非常大的DNN都不是什么好主意:我们应该总是试着找一个已有的能够完成类似任务的神经网络,然后重用这个网络的底层。这个技术被称为迁移学习。这不仅会使训练速度极大提高,并且还减小了对训练数据的需求。
假设我们已经有一个训练好的DNN可以将图片分成100种不同类别,包括动物、植物、车辆和日常物品。现在我们又需要训练一个DNN,用来分类特定类型的车辆。这两个任务非常相似,甚至部分重叠,那么我们就可以尝试重用第一个网络的部分内容。
如果新任务的输入图片与原始任务中使用的图片大小不一,那么就需要一个预处理步骤来将它们调整至原始模型所期望的尺寸。更普遍的说法是,当输入具有相似的低水平特征时,迁移学习的效果最好。
原始模型的输出层一般都是要被替换掉的,因为它一般对新任务一点用都没有,很可能输出的数量都不对。
类似的,原始模型的顶层也不像底层那样有用,因为对新任务最有用的高级特性很可能与原始任务的最有用的高级特性完全不同。所以需要找到可以重用的层的正确数量。
任务越相似,可以重用的层就越多(从较低的层开始)。对于非常相似的任务来说,就可以尝试保留所有的隐藏层,只替换输出层。
首先应该尝试冻结所有重用的层(即将它们的权重设置为不可训练,这样梯度下降就不会修改它们),然后训练模型,观察它的表现如何。然后尝试解冻一个或两个顶部隐藏层,然后让反向传播调整它们的参数,观察模型性能是否有提升。拥有的训练数量越多,可以解冻的层就越多。当层被解冻之后,它还有助于降低学习率:这可以避免破坏它们的微调权重。
如果模型的性能表现依然不理想,并且训练数据很少,那么可以尝试删除顶部的隐藏层,并再次冻结所有剩余的隐藏层。可以反复迭代上述操作,直到找到要重用的层数为止。但如果你有大量的训练数据,就可以尝试替换顶层隐藏层结构,而不是删除它们,甚至还可以添加更多的隐藏层。
使用Keras进行迁移学习
让我们来看个例子。假设时尚MNIST数据集除去凉鞋和衬衫,只有8个类别。现有一个基于上述数据集的已经训练完成的Keras模型,并且性能表现良好(准确度>90%),我们称之为模型A。现在,我们需要完成另一项任务:我们拥有凉鞋和衬衫的图像,需要训练一个二进制分类器(正=衬衫,负=凉鞋)。然而现在的数据集规模相当小,假设只有200个带标签的图像。当我们为这个任务训练一个新模型(我们称之为模型B)时,它的模型架构与模型A相同,并且运行得相当好(97.2%的准确率)。但由于这是个简单得多的分类任务(只有两个类),我们希望模型能够有更加强大的能力。由于任务B和任务A其实十分相似,那么我们是不是可以利用迁移学习做一点事情?让我们来试一下。
首先,我们需要加载模型A,并且基于模型A的层来创建一个新的模型。我们先来重用除了输出层以外的所有层:
model_A = keras.models.load_model("my_model_A.h5")
model_B_on_A = keras.models.Sequential(model_A.layers[:-1])
model_B_on_A.add(keras.layers.Dense(1, activation="sigmoid"))
现在model_A和model_B_on_A现在有了相同的层。当训练model_B_on_A时,它也会影响model_A。如果你希望避免这样的问题,就需要在重用model_A的层之前,克隆一个model_A出来。那么如何克隆模型呢?首先需要克隆模型A的结构,然后复制它的权重(clone_model()方法并不会复制权重):
model_A_clone = keras.models.clone_model(model_A)
model_A_clone.set_weights(model_A.get_weights())
那么现在我们就可以训练model_B_on_A了,但是由于新的输出层是随机初始化的,它会在训练最初的几个epoch期间有很大的误差。因此它会有很大的错误梯度,这可能会破坏重用层的权重。为避免这种情况,我们可以在训练最初的几个epoch期间将重用层冻结,这样使得新加的层在此期间学习到相对合理的权重。在Keras中,只需将层的trainable参数设置为False,然后编译模型即可:
for layer in model_B_on_A.layers[:-1]:
layer.trainable = False
model_B_on_A.compile(loss="binary_crossentropy", optimizer="sgd",
metrics=["accuracy"])
注意,对于模型的层进行了冻结或者解冻的操作之后,必须要编译一次。模型在训练了几个epoch之后再解冻重用层(需要再编译一次模型),随后再继续训练以精调重用层。在解冻重用的层之后,一般需要降低学习率,避免再次破坏重用的权重:
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=4,
validation_data=(X_valid_B, y_valid_B))
for layer in model_B_on_A.layers[:-1]:
layer.trainable = True
optimizer = keras.optimizers.SGD(lr=1e-4) # 默认lr为1e-2
model_B_on_A.compile(loss="binary_crossentropy", optimizer=optimizer,
metrics=["accuracy"])
history = model_B_on_A.fit(X_train_B, y_train_B, epochs=16,
validation_data=(X_valid_B, y_valid_B))
那么,最终结果如何呢?结果是模型的测试准确度为99.25%,也就是说迁移学习将错误率从2.8%降低到了0.7%!这是1/4的错误率。
>>> model_B_on_A.evaluate(X_test_B, y_test_B)
[0.06887910133600235, 0.9925]
看完上面的例子,你相信迁移学习的力量了吗?其实你根本不应该相信,因为我在上面的例子里作弊了!我尝试了很多模型配置,直到找到了其中一种配置,使模型看起来提升了非常多。其实如果改变了选择的服饰类别,或者改变随机种子,那么模型性能就不会提升那么多,甚至不提升或者性能更差了。这种做法被称为:“折磨数据,直到它坦白。”当一篇论文的结果看上去过于出色,其实你应该持怀疑态度:那些看上去很炫的技术可能并没有什么效果(甚至可能降低性能),但是作者尝试了很多变体,然后只报道了最好的结果(可能是由于运气得到了这样的结果),根本没有说在试验过程中有失败的例子。很多时候研究人员可能并不是有意这样做的,但这就是很多科学成果无法复现的原因之一。
那么我为什么需要作弊呢?因为迁移学习在小而密集型的神经网络上并不能发挥作用,可能是因为小型神经网络学习的模式很少,而密集型的网络学习的模式又十分具体,那么这样的模型对于别的任务就不太有用。迁移学习更适合深度卷积神经网络,它能够学习更加通用的特征(特别是在较低的层中)。我们会在未来的文章当中进一步讨论迁移学习,当然下次不会再作弊了。
本文介绍了最后一种常用的解决梯度爆炸问题的方法:梯度裁剪,还简单介绍了迁移学习的概念和基本方法。但是,如果我们想用迁移学习,却又找不到类似的模型可以用来迁移该怎么办呢?下一篇文章会介绍如何解决。
敬请期待吧!