数据准备
本文中以 0~9 数字图片识别,首先准备图片数据,这里使用到的是 MNIST 数据集,借助 Keras 实现:
import os
import tensorflow as tf # 导入 TF 库
from tensorflow import keras # 导入 TF 子库
from tensorflow.keras import layers, optimizers, datasets # 导入 TF 子库
(x, y), (x_val, y_val) = datasets.mnist.load_data() # 加载数据集
x = 2*tf.convert_to_tensor(x, dtype=tf.float32)/255.-1 # 转换为张量,缩放到-1~1
y = tf.convert_to_tensor(y, dtype=tf.int32) # 转换为张量
y = tf.one_hot(y, depth=10) # one-hot 编码
print(x.shape, y.shape)
train_dataset = tf.data.Dataset.from_tensor_slices((x, y)) # 构建数据集对象
train_dataset = train_dataset.batch(512) # 批量训练
一张图片我们用 shape 为 [h, w] 的矩阵来表示,对于多张图片来说,我们在前面添加一个数量维度(Dimension),使用 shape 为 [𝑐,ℎ,𝑥],𝑐 代表了 batch size(批量);多张彩色图片可以使用 shape 为 [𝑐,ℎ,𝑥,𝑑] 的张量来表示,其中的𝑑表示通道数量(Channel)。
多输入多输出神经元
假如说输入的维度为 d in d_{\text{in}} din,那么使用线性激活函数(恒等激活函数) output = input \operatorname {output } = \operatorname {input} output=input 实现的的感知器(Perceptron)的数学表达为:
y = w T x + b = [ w 1 , w 2 , w 3 , … , w d n ] ⋅ [ x 1 x 2 x 3 … x d i n ] + b y = \boldsymbol { w } ^ { T } \boldsymbol { x } + b = \left[ w _ { 1 } , w _ { 2 } , w _ { 3 } , \ldots , w _ { d _ { n } } \right] \cdot \left[ \begin{array} { c } x _ { 1 } \\ x _ { 2 } \\ x _ { 3 } \\ \dots \\ x _ { d _ { i n } } \end{array} \right] + b y=wTx+b=[w1,w2,w3,…,wdn]⋅⎣⎢⎢⎢⎢⎡x1x2x3…xdin⎦⎥⎥⎥⎥⎤+b
现将这种 Perceptron 拓展到多输入多输出的神经元模型,假如输入维度为 d in d_{\text{in}} din,输出维度为 d out d_{\text{out}} dout,可以写为:
y = W x + b \boldsymbol { y } = W \boldsymbol { x } + \boldsymbol { b } y=Wx+b
其中 x ∈ R d i n , b ∈ R d o u t , y ∈ R d o u t , W ∈ R d o u t × d i n \boldsymbol { x } \in R ^ { d _ { i n } } , \boldsymbol { b } \in R ^ { d _ { o u t } } , \boldsymbol { y } \in R ^ { d _ { o u t } } , W \in R ^ { d _ { o u t } \times d _ { i n } } x∈Rdin,b∈Rdout,y∈Rdout,W∈Rdout×din 。
对于多输出节点、批量训练方式,加入批量数据的数据量为 k k k,现将模型写成张量形式:
Y = X @ W + b \mathrm { Y } = \mathrm { X } @ \mathrm { W } + \boldsymbol { b } Y=X@W+b
其中 X ∈ R k × d i n , b ∈ R d o u t , Y ∈ R k × d o u t , W ∈ R d i n × d o u t \mathrm X \in R ^ { k \times d _ { i n } } , \boldsymbol { b } \in R ^ { d _ { o u t } } , \mathrm { Y } \in R ^ { k \times d _ { o u t } } , \mathrm W \in R ^ { d _ { i n } \times d _ { o u t } } X∈Rk×din,b∈Rdout,Y∈Rk×dout,W∈Rdin×dout,@符号表示矩阵相乘( Matrix Multiplication,matmul )。
现在假设输入样本数为 2,输入特征长度为 3,输出维度为 2:
[ o 1 1 o 2 1 o 1 2 o 2 2 ] = [ x 1 1 x 2 1 x 3 1 x 1 2 x 2 2 x 3 2 ] [ w 11 w 12 w 21 w 22 w 31 w 32 ] + [ b 1 b 2 ] \left[ \begin{array} { l l } o _ { 1 } ^ { 1 } & o _ { 2 } ^ { 1 } \\ o _ { 1 } ^ { 2 } & o _ { 2 } ^ { 2 } \end{array} \right] = \left[ \begin{array} { l l l } x _ { 1 } ^ { 1 } & x _ { 2 } ^ { 1 } & x _ { 3 } ^ { 1 } \\ x _ { 1 } ^ { 2 } & x _ { 2 } ^ { 2 } & x _ { 3 } ^ { 2 } \end{array} \right] \left[ \begin{array} { l l } w _ { 11 } & w _ { 12 } \\ w _ { 21 } & w _ { 22 } \\ w _ { 31 } & w _ { 32 } \end{array} \right] + \left[ \begin{array} { l } b _ { 1 } \\ b _ { 2 } \end{array} \right] [o11o12o21o22]=[x11x12x21x22x31x32]⎣⎡w11w21w31w12w22w32⎦⎤+[b1b2]
上述公式对应的模型结构图为:
可以看到,通过张量形式表达网络结构,更加简洁清晰,同时也可充分利用张量计算的并行加速能力。那么怎么将图片识别任务的输入和输出转变为满足格式要求的张量形式呢?
输入处理(one-hot 编码)
一张图片𝒚使用矩阵方式存储,shape 为:[ℎ,𝑥],𝑐 张图片使用 shape
为 [𝑐,ℎ,𝑥] 的张量 X 存储。而现在的模型只能接受向量形式的输入特征向量,因此需要将 [ℎ,𝑥] 的矩阵形式图片特征平铺成 [ℎ ∗ 𝑥] 长度的向量。以下图为例:
同时一般的数据标签都是用一个数字来表示便签信息,假如说 1、2、3、4 分别对应的标签是猫、狗、鱼、鸟,这里的 1、2、3、4 只是表示的 ID 标签,但是数字本身的大小关系迫使机器学习模型来学习这种大小约束关系。所以现在提出一种编码方式:one-hot 编码。那么对于某数据集,总类别数为 n,假设某个样本属于类别 𝑖,即图片的中数字为 𝑖,只需要一个长度为 n 的向量 𝐲 ,向量 𝐲 的索引号为 𝑖 的元素设置为 1,其他位为 0。
One-hot 编码是非常稀疏 (Sparse) 的,相对于数字编码来说,占用较多的存储空间,所以一般在存储时还是采用数字编码。
利用 TensorFlow 实现:
y = tf.constant([0,1,2,3]) # 数字编码
y = tf.one_hot(y, depth=10) # one-hot 编码
print(y)
其中 one_hot
中的参数 depth
表示的是 one-hot 编码后的向量长度。
评价函数(损失函数)
对于分类问题来说,目标是最大化某个性能指标,比如准确度 ,但是把准确度当做损失函数去优化时,会发现是不可导的,无法利用梯度下降算法优化网络参数。一般的做法是,设立一个平滑可导的代理目标函数,比如优化模型的输出 与 One-hot 编码后的真实标签 y 之间的距离(Distance)。因此,相对回归问题而言,分类问题的优化目标函数和评价目标函数是不一致的。
模型的训练目标是通过优化损失函数 L \mathcal L L 来找到最优数值解 W ∗ , b ∗ W^∗, \boldsymbol b^∗ W∗,b∗。
W ∗ , b ∗ = argmin W , b L ( o , y ) \mathrm { W } ^ { * } , \boldsymbol { b } ^ { * } = { \mathop { \text {argmin} } \limits_ { W , \boldsymbol { b } } \mathcal { L } ( \boldsymbol { o } , \boldsymbol { y } ) } W∗,b∗=W,bargminL(o,y)
这里采用均方差损失函数,那么对于𝑁个样本的均方差损失函数为:
L ( o , y ) = 1 N ∑ i = 1 N ∑ j = 1 n ( o j i − y j i ) 2 \mathcal { L } ( \boldsymbol { o } , \boldsymbol { y } ) = \frac { 1 } { N } \sum _ { i = 1 } ^ { N } \sum _ { j = 1 } ^ { n} \left( o _ { j } ^ { i } - y _ { j } ^ { i } \right) ^ { 2 } L(o,y)=N1i=1∑Nj=1∑n(oji−yji)2
现在我们只需要采用梯度下降算法来优化损失函数得到 W , b W, \boldsymbol b W,b 的最优解,利用求得的模型去预测未知的手写数字图片 x ∈ D test \boldsymbol { x } \in \mathbb { D } ^ { \text {test} } x∈Dtest 。
非线性激活函数
激活函数实际上是对输入 x 的线性组合做了进一步的处理然后输出,也就下式中的 σ \sigma σ:
o = σ ( W x + b ) \boldsymbol { o } = \sigma ( \boldsymbol { W } \boldsymbol { x } + \boldsymbol { b } ) o=σ(Wx+b)
而恒等激活函数就是对其直接输出,即:
z = σ ( z ) z = \sigma (z) z=σ(z)
但是有严格证明可以知道当使用线性激活函数时,无论经过多少层的神经元,其输出仍然是输入 x 的线性组合。所以需要使用一些非线性激活函数增强模型的能力(power)。常见的非线性激活函数有Sigmoid 函数(图 a),ReLU 函数(图 b)
ReLU 函数非常简单,仅仅是在 𝑦 = 𝑥 在基础上面截去了 𝑥 < 0 的部分,可以直观地理解为 ReLU 函数仅仅保留正的输入部份,清零负的输入。ReLU 函数的具体实现为:
f ( x ) = max ( 0 , x ) f ( x ) = \max ( 0 , x ) f(x)=max(0,x)
虽然简单,ReLU 函数却有优良的非线性特性,而且梯度计算简单,训练稳定,是深度学习模型使用最广泛的激活函数之一。下文中会嵌套 ReLU 函数将模型转换为非线性模型:
o = ReLU ( W x + b ) \boldsymbol { o } = \operatorname { ReLU } ( \boldsymbol { W } \boldsymbol { x } + \boldsymbol { b } ) o=ReLU(Wx+b)
多层感知器堆叠
针对于模型的表达能力偏弱的问题,可以通过重复堆叠多次变换来增加其表达能力:
h 1 = ReLU ( W 1 x + b 1 ) h 2 = ReLU ( W 2 h 1 + b 2 ) o = W 3 h 2 + b 3 \begin{array} { c } \boldsymbol { h } _ { \mathbf { 1 } } = \operatorname { ReLU } \left( \boldsymbol { W } _ { \mathbf { 1 } } \boldsymbol { x } + \boldsymbol { b } _ { \mathbf { 1 } } \right) \\ \boldsymbol { h } _ { \mathbf { 2 } } = \operatorname { ReLU } \left( \boldsymbol { W } _ { \mathbf { 2 } } \boldsymbol { h } _ { \mathbf { 1 } } + \boldsymbol { b } _ { 2 } \right) \\ \boldsymbol { o } = \boldsymbol { W } _ { 3 } \boldsymbol { h } _ { 2 } + \boldsymbol { b } _ { 3 } \end{array} h1=ReLU(W1x+b1)h2=ReLU(W2h1+b2)o=W3h2+b3
把第一层神经元的输出值 h 1 h_1 h1 作为第二层神经元模型的输入,把第二层神经元的输出 h 2 h_2 h2 作为第三层神经元的输入,最后一层神经元的输出作为模型的输出 。
从下图所示的网络结构上看,函数的嵌套表现为网络层的前后相连,每堆叠一个(非)线性环节,网络层数增加一层。我们把数据节点所在的层叫做输入层,每一个非线性模块的输出 h i h_i hi 连同它的网络层参数 𝑾𝒋 和 𝒃𝒋 称为一层网络层,特别地,对于网络中间的层,叫做隐藏层,最后一层叫做输出层。这种由大量神经元模型连接形成的网络结构称为(前馈)神经网络(Neural Network)。
优化方法
优化方法采用的是经典的反向传播算法。具体推导在博文 机器学习技法 之 神经网络(Neural Network) 中有介绍过。学过之后会发现,可以根据后一层的梯度值求前一层的梯度值,这样就实现了反向传播,并且可以得出一个可以用于任何网络结构的计算范式,所以深度学习框架通过该范式自动获得梯度的大小,从而方便模型的实现。
TensorFlow 实现
网络搭建
对于前文提到的三层网络,其输入层的神经网络模型由 256 个神经元组成,在 TensorFlow 中通过一行代码即可实现:
layers.Dense(256, activation='relu')
使用 TensorFlow 的 Sequential 容器可以非常方便地搭建多层的网络。对于 3 层网络,对于前文提到的三层网络,可以使用以下语句实现:
model = keras.Sequential([ # 3 个非线性层的嵌套模型
layers.Dense(256, activation='relu'),
layers.Dense(128, activation='relu'),
layers.Dense(10)])
模型训练 得到模型输出 后,通过 MSE 损失函数计算当前的误差ℒ
with tf.GradientTape() as tape:
# 打平操作,[batch, 28, 28] => [batch, 784]
x = tf.reshape(x, (-1, 28*28))
# Step1. 得到模型输出output [batch, 784] => [batch, 10]
out = model(x)
# [batch] => [batch, 10]
y_onehot = tf.one_hot(y, depth=10)
# Step2. 计算均方误差 [batch, 10] => [batch]
loss = tf.reduce_sum(tf.square(out-y_onehot)) / x.shape[0]
acc_meter.update_state(tf.argmax(out, axis=1), y)
# Step3. 计算参数的梯度 w1, w2, w3, b1, b2, b3
grads = tape.gradient(loss, model.trainable_variables)
# w' = w - lr * grad, 更新网络参数
optimizer.apply_gradients(zip(grads, model.trainable_variables))
最终实现
import tensorflow as tf
from tensorflow.keras import datasets, layers, optimizers, Sequential, metrics
# 设置GPU使用方式
# 获取GPU列表
gpus = tf.config.experimental.list_physical_devices('GPU')
if gpus:
try:
for gpu in gpus:
# 设置GPU为增长式占用
tf.config.experimental.set_memory_growth(gpu, True)
except RuntimeError as e:
# 打印异常
print(e)
# 导入数据
(x_train, y_train),(x_val, y_val) = datasets.mnist.load_data()
print('datasets:', x_train.shape, y_train.shape, x_train.min(), x_train.max())
# 将样本属性转换为张量
x_train = tf.convert_to_tensor(x_train, dtype=tf.float32) / 255.
x_val = tf.convert_to_tensor(x_val, dtype=tf.float32) / 255.
# 每批次的样本个数
batch_size = 30
# 模型迭代次数
epochs = 30
# 序列模型 Sequential 适用于每层只有一个输入张量和一个输出张量的简单层堆栈
model = Sequential([layers.Dense(256, activation='relu'),
layers.Dense(128, activation='relu'),
layers.Dense(10)])
# input_shape 为输入层的形状参数 None 代表任意批次 28* 28 代表输入参数维度
model.build(input_shape=(None, 28*28))
# 序列模型信息打印
model.summary()
# 随机梯度下降法 优化器
optimizer = optimizers.SGD(lr=0.01)
# 将样本属性和标签转换为张量
dataset_train = tf.data.Dataset.from_tensor_slices((x_train,y_train))
# 将样本批次大小设置为 batch_size,并将样本数据复制 30 次
dataset_train = dataset_train.batch(batch_size).repeat(epochs)
# metrics 是用来判断模型性能的
# 其中的准确率容器 Accuracy 判断某批次中预测值与实际值是否相同(二值准确率)
acc_meter = metrics.Accuracy()
def val_acc(x,y):
# 打平操作,[batch, 28, 28] => [batch, 784]
x = tf.reshape(x, (-1, 28*28))
# Step1. 得到模型输出output [batch, 784] => [batch, 10]
out = model(x)
# one-hot 编码 [batch] => [batch, 10]
y_onehot = tf.one_hot(y, depth=10)
# Step2. 计算均方误差 [batch, 10] => [1]
loss = tf.reduce_sum(tf.square(out-y_onehot)) / x.shape[0]
acc_meter.reset_states()
acc_meter.update_state(tf.argmax(out, axis=1), y)
print('val_loss:', float(loss), 'val_acc:', acc_meter.result().numpy())
# 遍历根据批次分割后数据样本集
for step, (x,y) in enumerate(dataset_train):
# 遍历根据批次分割后数据样本集
with tf.GradientTape() as tape:
# 打平操作,[batch, 28, 28] => [batch, 784]
x = tf.reshape(x, (-1, 28*28))
# Step1. 得到模型输出output [batch, 784] => [batch, 10]
out = model(x)
# one-hot 编码 [batch] => [batch, 10]
y_onehot = tf.one_hot(y, depth=10)
# Step2. 计算均方误差 [batch, 10] => [1]
loss = tf.reduce_sum(tf.square(out-y_onehot)) / x.shape[0]
# 向准确率容器中添加当前训练样本的预测值与实际值,用于更新当前批次准确率
# 可以使用参数 sample_weight=[1, 1, 0, 0 ... 1] 用于计算加权准确率
acc_meter.update_state(tf.argmax(out, axis=1), y)
# Step3. 计算参数的梯度 w1, w2, w3, b1, b2, b3
grads = tape.gradient(loss, model.trainable_variables)
# Step4. 根据公式 w' = w - lr * grad 更新网络参数
optimizer.apply_gradients(zip(grads, model.trainable_variables))
if step % 200==0:
print(step, 'loss:', float(loss), 'acc:', acc_meter.result().numpy(),end=' ')
val_acc(x_val,y_val)
# 每 200 个数据量大小为 batchsize 数据集作为一个批次数据
# 之后重置准确率容器中的缓存,从新计算准确率
acc_meter.reset_states()