本次课的所有示例代码和所用数据都可以从本课Github仓库上获得。为了更清楚地梳理老师上课的讲授内容,这次课的笔记我打算使用如下方法进行组织
- 首先把教授给出的初始代码框架贴出来
- 由于通常初始代码会分成几块,因此,之后会按照算法逻辑,给出各个块的实现
- 如果这里遇到了TensorFlow的一些新的,之前没有讲的知识点,补上
- 如果这里遇到了一些理论上的知识点(例如不同优化器),对老师的讲义做一个摘抄
那么就开始吧
使用TensorFlow从头实现线性回归
本次课的第一项内容是实现一个线性回归模型。这个模型的输入X
是190个国家的出生率,输出Y
是该国家的期望寿命。初始代码如下
1 | """ Starter code for simple linear regression example using placeholders |
定义计算图
使用占位符定义变量
这块比较直接,用占位符定义X
和Y
就可以了。在讲义里,“变量”这个词对TF的完全新手来说,可能会有歧义,因为会让人想起tf.Variable
。关于tf.placeholder
和tf.Variable
的区别,可以参考这篇StackOverflow的问答,我在这里做个摘要:tf.placeholder
通常用来存储用来训练模型的数据和标签(这里就是X
和Y
),而tf.Variable
通常用来定义要求解的模型变量(这里要训练一个线性模型,对应的就是权重W
和偏置b
)
讲义中的定义方法如下,不过没有显式地指定X
和Y
的形状。根据前面的授课内容,这似乎会使调试过程比较痛苦
1 | X = tf.placeholder(tf.float32, name='X') |
定义训练变量并初始化
这里定义的是模型要求解的变量,由上面的说明,应该使用tf.get_variable
。注意定义变量的时候要初始化,这里使用0来做初始值。如果使用常数做初始化函数,就不用指定形状
1 | w = tf.get_variable('weights', initializer=tf.constant(0.0)) |
定义预测值
直接写出如何使用模型做预测就可以。这里每次输入的X
实际是一个标量,所以可以直接使用*
符号
1 | Y_predicted = w * X + b |
定义损失函数
也是直接写出平方误差的计算式。由于这里是每读入一条数据做一次计算,因此不用求和。如果需要求和,使用tf.reduce_sum()
就可以
1 | loss = tf.square(Y - Y_predicted, name='loss') |
定义优化器
尽管可以手写定义梯度下降的计算式,不过TensorFlow已经提供了一个很好的封装,直接调用就可以。TF还提供了其它优化器,其原理在之后详细介绍(课程里最推荐的优化器是Adam优化器)
这里有一点值得一提:tf.train
中定义的所有优化器都有一个成员方法minimize
,这个方法的签名里写明可以传入参数var_list
,而其功能写明就是“通过更新var_list
中的变量来最小化loss
”。那么为什么这里没有传入参数var_list
以指明更新什么变量呢?原因是前面定义w
和b
的时候默认指明了其trainable
为True
,而且loss
的定义依赖了这两个变量,因此TF知道需要更新(训练)这两个变量
1 | optimizer = tf.train.GradientDescentOptimizer(learning_rate=0.001).minimize(loss) |
可以通过tf.stop_gradient
来防止某些张量参与某个损失函数导数的计算。如果想在训练过程中“冻结”某些特定的变量,就可以使用这样的操作。例如,训练GAN时,在生成对抗样本的过程中,是不需要反向传播的
Optimizer
类会自动计算图中的导数,不过也可以通过tf.gradients
来写明计算某些指定的梯度。当要训练模型的一部分时,适合使用这种方式
训练模型
初始化变量
要初始化w
和b
,只需要调用前面讲过的tf.global_variables_initializer()
就可以。这里还可以多走一步,创建一个tf.summary.FileWriter
类对象来写日志,使得可以在TensorBoard上观察训练过程
1 | sess.run(tf.global_variables_initializer()) |
训练模型
训练模型的过程实际上就是求解优化器和损失函数值的过程,因此可以如下调用sess.run
。注意计算这两个值的时候用到的X
和Y
只使用了占位符代替,因此要在run
的时候指定一个feed_dict
来传入实际值
1 | for i in range(100): |
这里optimizer
和loss
,更规范地说,是要计算的两个张量
输出模型变量
这部分相对来讲更加简单,只需要向run
方法传入要“获得”(fetch)的变量就可以
1 | w, b = sess.run([w, b]) |
完整的代码在github上可以获得
改进方案
从最后得到的图中可以看到,数据集中有一些离群点(outlier)。离群点的存在会影响线性回归模型的求解。为了避免这一点,可以使用如下定义的Huber loss让模型更加鲁棒 \[
L_\delta (y, f(x)) = \begin{cases} \frac{1}{2}(y-f(x))^2 & {\rm for\ }|y-f(x)| \le \delta \\ \delta|y-f(x)| - \frac{1}{2}\delta^2 & {\rm otherwise}\end{cases}
\] 需要注意的是,在实现时,不能直接写if Y-Y_predicted < delta
。因为Y
和Y_predict
都是变量,根据StackOverflow上的讨论,一方面,TF变量之间的比较需要使用操作tf.less()
;另一方面,对tf.less()
得到的结果,需要调用Session
对象的run
方法才能得到python的布尔变量。达到同样的目的的一种更简单的做法是调用tf.cond
操作,这个操作有点像Excel里的IF函数:第一个参数是一个条件,第二个参数是条件为真时调用的函数,第二个参数是条件为假时调用的函数。因此huber loss完整的TF实现如下:
1 | def huber_loss(labels, predictions, delta=1.0): |
tf.data
根据Derek Murray的文章,占位符和feed dict的好处是它们把数据处理的过程放在了TF的框架之外,因此打乱数据顺序、构建batch等操作用python实现起来比较容易。但是这种做法可能会让程序变慢,因为这些数据处理的代码通常都是单线程运行,所以会造成性能瓶颈。作为处理数据的另一种方法,可以使用TensorFlow提供的队列来完成同样的任务,这样做还能享受管道操作、多线程带来的好处,降低了时间损耗。但是这种方法不太容易使用,而且容易崩溃
TensorFlow从1.4版本开始,将原先contrib包中的tf.contrib.data下的API移动到了核心API中。这样,输入数据可以存放在tf.data.Dataset
对象中,用法如下
1 | tf.data.Dataset.from_tensor_slices((features, labels)) |
尽管features
和labels
应该是张量,但是由于TF和numpy可以无缝集成,因此实际使用时也可以传入numpy的array,即
1 | dataset = tf.data.Dataset.from_tensor_slices((data[:, 0], data[:, 1])) |
其它常用的Dataset还包括
tf.data.TextLineDataset
。其要求文件的每一行都是一个数据项,适用于机器翻译tf.data.FixedLengthRecordDataset
。其要求每条数据长度都一样,适用于CIFAR和ImageNet等tf.data.TFRecordDataset
。适用于存储为tfrecord格式的数据
Dataset
对象有batch
、shuffle
、repeat
等方法,也支持通过map
来创建一个新的对象
将数据转化为Dataset
对象以后,可以使用迭代器进行迭代,它在每次调用get_next()
的时候都会返回一个新的样本或者batch。如果需要迭代多个epoch,需要使用dataset.make_initializable_iterator
。关键代码如下:
1 | data, n_samples = utils.read_birth_life_data(DATA_FILE) |
注意所有样本被迭代一遍以后会抛出tf.errors.OutOfRangeError
这个异常。该异常TensorFlow不会处理,需要自己手工应对
使用TensorFlow从头实现Logistic回归
本次课程的第二个部分是实现一个Logistic回归模型。这里使用的数据集是Hinton经常拿来用的MNIST手写数据集,因此X
是原始的图片像素值,而Y
是这张图片对应的实际数字。初始代码如下所示
1 | """ Starter code for simple logistic regression model for MNIST |
模型的实现仍然是总体上分为定义计算图和训练模型两个部分,而定义计算图时也包括了使用占位符、定义训练变量等过程,整体上和线性回归类似。因此为了简单起见,本节不再新建小小节来描述这些过程,而且讲解也会变短
定义计算图
Logistic回归的计算图分如下几步定义
使用占位符定义变量:
1
2X = tf.placeholder(tf.float32, [batch_size, 784], name='image')
Y = tf.placeholder(tf.float32, [batch_size, 10], name='label')原来的图像是一个\(28 \times 28\)的黑白图片,这里对每张图片把这个矩阵拍平成了一个长度为784的向量。我们不再像前面线性回归那样每次只读入一条数据,而是一次读入
batch_size
条数据。整个模型的损失函数实际上也是使用mini-batch SGD进行优化定义训练变量并初始化。这里与前一个例子类似
1
2w = tf.Variable(tf.random_normal(shape=[784, 10], stddev=0.01), name='weights')
b = tf.Variable(tf.zeros([1, 10]), name='bias')定义预测值和损失函数。与前面讲过的Logistic回归稍有不同:前面讲的Logistic回归大多是处理二元分类问题,因此可以直接使用Logistic回归将\({\bf w}^\mathsf{T}{\bf x} + \bf b\)的结果映射到\((0, 1)\)区间;这里要处理的是一个多元分类问题,相应的是要使用Softmax函数对最后的得分进行处理,得到\(\bf x\)属于每个类别的概率。其常用的损失函数是交叉熵(cross-entropy)函数,具体的原理在之后的理论部分做进一步的讲解。代码如下
1
2
3logits = tf.matmul(X, w) + b
entropy = tf.nn.softmax_cross_entropy_with_logits(logits=logits, labels=Y, name='loss')
loss = tf.reduce_mean(entropy)定义优化器。这里使用了Adam优化器
1
optimizer = tf.train.AdamOptimizer(learning_rate).minimize(loss)
训练模型
训练模型的代码和线性回归相比没什么变化
1 | _, loss_batch = sess.run([optimizer, loss], feed_dict={X: X_batch, Y: Y_batch}) |
这样,Logistic回归的模型也使用TF实现完毕。完整的代码可以参看课程github
课件中提供了一个非常有用的贴士:如果mini batch开得比较大,就需要多训练几个epoch,因为只有这样才能保证对权重足够多次数的更新