养成良好的单元测试习惯,对于提升开发效率和代码质量至关重要,特别是在机器学习领域,很多问题并不是一眼就能发现的。
在过去的一年里,我专注于深度学习的研究和实践,期间犯了许多错误。这些错误不仅帮助我更好地理解了机器学习,也教会了我如何设计出更加稳健的系统。在谷歌Brain工作时,我学到了一个重要原则:单元测试是决定算法成功与否的关键,它可以帮你节省大量的调试和训练时间。
然而,我发现为神经网络代码编写单元测试的方法并不成熟,即使是像OpenAI这样的机构,也只是通过仔细检查每行代码来寻找潜在的问题。显然,我们大多数人没有那么多时间去这么做。因此,我希望这篇教程可以帮助大家更高效地测试自己的系统。
让我们从一个简单的例子开始。尝试找出下面代码中的错误:
python
def make_convnet(input_image):
net = slim.conv2d(input_image, 32, [11, 11], scope="conv1_11x11")
net = slim.conv2d(input_image, 64, [5, 5], scope="conv2_5x5")
net = slim.max_pool2d(net, [4, 4], stride=4, scope='pool1')
net = slim.conv2d(input_image, 64, [5, 5], scope="conv3_5x5")
net = slim.conv2d(input_image, 128, [3, 3], scope="conv4_3x3")
net = slim.max_pool2d(net, [2, 2], scope='pool2')
net = slim.conv2d(input_image, 128, [3, 3], scope="conv5_3x3")
net = slim.max_pool2d(net, [2, 2], scope='pool3')
net = slim.conv2d(input_image, 32, [1, 1], scope="conv6_1x1")
return net
你能发现其中的问题吗?实际上,这段代码并没有按照预期构建网络。在编写这段代码时,我复制并粘贴了一些slim.conv2d(...)语句,但忘记修改实际的输入部分。
我必须承认,这种错误在过去一周内也曾发生在我的身上……但这确实是一堂重要的课程。因为某些原因,这类错误很难被捕捉。
当你唯一的反馈只是最终的验证错误时,唯一需要检查的地方就是整个网络架构。显然,你需要一个更有效的系统。
那么,在我们进行完整的多天训练之前,如何才能真正抓住这个机会呢?最直观的观察点是层的值实际上并不会传递给函数外部的任何其他张量。假设我们有一种损失函数和一个优化器,这些张量将永远不会得到优化,因此它们总是保留默认值。
我们可以通过简单的训练步骤和前后对比来检测这个问题:
python
def test_convnet():
image = tf.placeholder(tf.float32, (None, 100, 100, 3))
model = Model(image)
sess = tf.Session()
sess.run(tf.global_variables_initializer())
before = sess.run(tf.trainable_variables())
_ = sess.run(model.train, feed_dict={
image: np.ones((1, 100, 100, 3)),
})
after = sess.run(tf.trainable_variables())
for b, a in zip(before, after):
# 确保至少有一个变量发生了变化
assert (b != a).any()
在不到15行代码中,我们现在验证了至少所有变量都得到了训练。
这个测试非常简单,也非常有用。假设我们修复了后面的问题,现在我们需要开始添加批归一化。看看你是否能发现这个bug:
python
def make_convnet(image_input):
# 尝试在卷积前对输入进行归一化
net = slim.batch_norm(image_input)
net = slim.conv2d(net, 32, [11, 11], scope="conv1_11x11")
net = slim.conv2d(net, 64, [5, 5], scope="conv2_5x5")
net = slim.max_pool2d(net, [4, 4], stride=4, scope='pool1')
net = slim.conv2d(net, 64, [5, 5], scope="conv3_5x5")
net = slim.conv2d(net, 128, [3, 3], scope="conv4_3x3")
net = slim.max_pool2d(net, [2, 2], scope='pool2')
net = slim.conv2d(net, 128, [3, 3], scope="conv5_3x3")
net = slim.max_pool2d(net, [2, 2], scope='pool3')
net = slim.conv2d(net, 32, [1, 1], scope="conv6_1x1")
return net
你能看到吗?这里非常巧妙。在TensorFlow的batch_norm中,默认情况下is_training参数为False,这意味着添加这一行代码并不能在训练期间对输入进行归一化。幸运的是,我们编写的最后一个单元测试会立即发现这个问题。
再来看一个例子。这源自我最近看到的一篇文章(https://www.reddit.com/r/MachineLearning/comments/6qyvvg/ptensorflowresponseismakingnosense/)。我不会详细解释,但基本上,有人想创建一个输入范围在(0,1)之间的分类器。
python
class Model:
def __init__(self, input, labels):
"""分类器模型
Args:
input: 输入张量,形状为(None, input_dims)
label: 标签张量,形状为(None, 1)。类型应为tf.int32。
"""
prediction = self.make_network(input)
# 预测张量形状为(None, 1)
self.loss = tf.nn.softmax_cross_entropy_with_logits(
logits=prediction, labels=labels)
self.train_op = tf.train.AdamOptimizer().minimize(self.loss)
你能注意到这个错误吗?这是一个很难提前发现的错误,可能会导致非常混乱的结果。基本上,这里的问题是预测只有一个输入,当你应用softmax交叉熵时,损失总是为零。
一个简单的测试方法是确保损失不为零:
python
def test_loss():
in_tensor = tf.placeholder(tf.float32, (None, 3))
labels = tf.placeholder(tf.int32, shape=(None, 1))
model = Model(in_tensor, labels)
sess = tf.Session()
loss = sess.run(model.loss, feed_dict={
in_tensor: np.ones((1, 3)),
labels: [[1]]
})
assert loss != 0
另一个类似的测试是确保只有你希望训练的变量得到了训练。以生成对抗网络(GAN)为例。一个常见的错误是在优化过程中不小心忘记了设置要训练的变量。这样的代码经常出现:
python
class GAN:
def __init__(self, z_vector, true_images):
# 假设这些方法已实现。
with tf.variable_scope("gen"):
self.make_generator(z_vector)
with tf.variable_scope("disc"):
self.make_discriminator(true_images)
opt = tf.train.AdamOptimizer()
train_disc = opt.minimize(self.disc_loss)
train_gen = opt.minimize(self.gen_loss)
最大的问题是优化器默认设置为优化所有变量。在像GAN这样的复杂架构中,这可能会浪费大量的训练时间。但你可以通过编写这样的测试来轻松发现这些错误:
python
def test_gen_training():
model = Model()
sess = tf.Session()
gen_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope='gen')
disc_vars = tf.get_collection(tf.GraphKeys.TRAINABLE_VARIABLES, scope='disc')
before_gen = sess.run(gen_vars)
before_disc = sess.run(disc_vars)
# 训练生成器
sess.run(model.train_gen)
after_gen = sess.run(gen_vars)
after_disc = sess.run(disc_vars)
# 确保生成器变量发生变化
for b, a in zip(before_gen, after_gen):
assert (a != b).any()
# 确保判别器变量未发生变化
for b, a in zip(before_disc, after_disc):
assert (a == b).all()
可以为判别器编写一个非常相似的测试。类似的测试也可以应用于许多强化学习算法。许多行为-评判模型有不同的网络,需要根据不同的损失进行优化。
下面是一些我建议你进行测试的样式:
总之,这些黑盒算法还有很多方法需要测试!花一个小时写一个测试可以节省你几天的时间重新训练模型,从而大大提高你的研究效率。因为我们实现中的缺陷而不得不放弃完美的想法,这不是很糟糕吗?
这个列表显然不全面,但它是一个坚实的起点!