TensorFlow
是一个面向于深度学习算法的科学计算库,内部数据保存在张量(Tensor)对象上,所有的运算操作(Operation, OP)也都是基于张量对象进行。复杂的神经网络算法本质上就是各种张量相乘、相加等基本运算操作的组合,在深入学习深度学习算法之前,熟练掌握 TensorFlow 张量的基础操作方法十分重要。
首先我们来介绍 TensorFlow 中的基本数据类型,它包含了数值型、字符串型和布尔型。
数值类型的张量是 TensorFlow 的主要数据载体,分为:
❑ 标量(Scalar) 单个的实数,如 1.2, 3.4 等,维度数(Dimension,也叫秩)为 0,shape 为[]
❑ 向量(Vector) n 个实数的有序集合,通过中括号包裹,如[1.2],[1.2,3.4]等,维度数为1,长度不定,shape 为[]
❑ 矩阵(Matrix) n 行 m 列实数的有序集合,如[[1,2],[3,4]],也可以写成
[ 1 2 3 4 ] \left[\begin{array}{ll} 1 & 2 \\ 3 & 4 \end{array}\right] [1324]
维度数为 2,每个维度上的长度不定,shape 为[, ]
❑ 张量(Tensor) 所有维度数dim > 2的数组统称为张量。张量的每个维度也做轴(Axis),一般维度代表了具体的物理含义,比如 Shape 为[2,32,32,3]的张量共有 4 维,如果表示图片数据的话,每个维度/轴代表的含义分别是:图片数量、图片高度、图片宽度、图片通道数,其中 2 代表了 2 张图片,32 代表了高宽均为 32,3 代表了 RGB 3 个通道。张量的维度数以及每个维度所代表的具体物理含义需要由用户自行定义
在 TensorFlow 中间,为了表达方便,一般把标量、向量、矩阵也统称为张量,不作区分,需要根据张量的维度数和形状自行判断。
首先来看标量在 TensorFlow 是如何创建的:
a=1.2
aa=tf.constant(1.2)# 创建标量
print(type(a))
print(type(aa))
print(tf.is_tensor(aa))
output:
<class 'float'>
<class 'tensorflow.python.framework.ops.EagerTensor'>
True
必须通过 TensorFlow
规定的方式去创建张量,而不能使用 Python 语言的标准变量创建方式。
通过 print(x)或 x 可以打印出张量 x 的相关信息:
x = tf.constant([1,2.,3.3])
<tf.Tensor: id=165, shape=(3,), dtype=float32, numpy=array([1. , 2. , 3.3],dtype=float32)>
其中 id 是 TensorFlow 中内部索引对象的编号,shape 表示张量的形状,dtype 表示张量的数值精度,张量 numpy()方法可以返回 Numpy.array 类型的数据,方便导出数据到系统的其他模块:
x.numpy()
array([1. , 2. , 3.3], dtype=float32)
与标量不同,向量的定义须通过 List 类型传给 tf.constant()
。创建一个元素的向量
a = tf.constant([1.2])
a, a.shape
(<tf.Tensor: id=8, shape=(1,), dtype=float32, numpy=array([1.2],dtype=float32)>,TensorShape([1]))
创建 2 个元素的向量:
a = tf.constant([1,2, 3.])
a, a.shape
(<tf.Tensor: id=11, shape=(3,), dtype=float32, numpy=array([1., 2., 3.],dtype=float32)>,TensorShape([3]))
同样的方法定义矩阵:
a = tf.constant([[1,2],[3,4]])
a, a.shape
(<tf.Tensor: id=13, shape=(2, 2), dtype=int32, numpy=array([[1, 2],[3, 4]])>, TensorShape([2, 2]))
3 维张量可以定义为:
a = tf.constant([
[
[1,2],[3,4]],[[5,6],[7,8]
]
])
<tf.Tensor: id=15, shape=(2, 2, 2), dtype=int32, numpy=array([[[1, 2],[3, 4]],[[5, 6],[7, 8]]])>
除了丰富的数值类型外,TensorFlow 还支持字符串(String)类型的数据,例如在表示图片数据时,可以先记录图片的路径,再通过预处理函数根据路径读取图片张量。通过传入字符串对象即可创建字符串类型的张量:
a = tf.constant('Hello, Deep Learning.')
<tf.Tensor: id=17, shape=(), dtype=string, numpy=b'Hello, Deep Learning.'>
在 tf.strings
模块中,提供了常见的字符串型的工具函数,如拼接 join(),长度 length(),切分 split()等等:
tf.strings.lower(a)
<tf.Tensor: id=19, shape=(), dtype=string, numpy=b'hello, deep learning.'>
深度学习算法主要还是以数值类型张量运算为主,字符串类型的数据使用频率较低,我们不做过多阐述。
为了方便表达比较运算操作的结果,TensorFlow 还支持布尔类型(Boolean
, bool)的张量。布尔类型的张量只需要传入 Python 语言的布尔类型数据,转换成 TensorFlow 内部布尔型即可:
a = tf.constant(True)
<tf.Tensor: id=22, shape=(), dtype=bool, numpy=True>
传入布尔类型的向量:
a = tf.constant([True, False])
<tf.Tensor: id=25, shape=(2,), dtype=bool, numpy=array([ True, False])>
需要注意的是,TensorFlow 的布尔类型和 Python 语言的布尔类型并不对等,不能通用:
a = tf.constant(True) # 创建布尔张量
a == True
False
对于数值类型的张量,可以保持为不同字节长度的精度,如浮点数 3.14 既可以保存为16-bit 长度,也可以保存为 32-bit 甚至 64-bit 的精度。Bit 位越长,精度越高,同时占用的内存空间也就越大。常用的精度类型有 tf.int16, tf.int32, tf.int64, tf.float16, tf.float32,tf.float64
,其中 tf.float64 即为 tf.double。在创建张量时,可以指定张量的保存精度:
tf.constant(123456789, dtype=tf.int16)
tf.constant(123456789, dtype=tf.int32)
<tf.Tensor: id=33, shape=(), dtype=int16, numpy=-13035>
<tf.Tensor: id=35, shape=(), dtype=int32, numpy=123456789>
可以看到,保存精度过低时,数据 123456789 发生了溢出,得到了错误的结果,一般使用tf.int32, tf.int64 精度。对于浮点数,高精度的张量可以表示更精准的数据,例如采用tf.float32 精度保存时:
import numpy as np
tf.constant(np.pi, dtype=tf.float32)
<tf.Tensor: id=29, shape=(), dtype=float32, numpy=3.1415927>
如果采用 tf.float64 精度保存,则能获得更高的精度:
tf.constant(np.pi, dtype=tf.float64)
<tf.Tensor: id=31, shape=(), dtype=float64, numpy=3.141592653589793>
对于大部分深度学习算法,一般使用 tf.int32, tf.float32 可满足运算精度要求,部分对精度要求较高的算法,如强化学习,可以选择使用 tf.int64, tf.float64 精度保存张量。
通过访问张量的 dtype 成员属性可以判断张量的保存精度:
print('before:',a.dtype)
if a.dtype != tf.float32:
a = tf.cast(a,tf.float32) # 转换精度
print('after :',a.dtype)
before: <dtype: 'float16'>
after : <dtype: 'float32'>
对于某些只能处理指定精度类型的运算操作,需要提前检验输入张量的精度类型,并将不符合要求的张量进行类型转换。
系统的每个模块使用的数据类型、数值精度可能各不相同,对于不符合要求的张量的类型及精度,需要通过 tf.cast
函数进行转换:
a = tf.constant(np.pi, dtype=tf.float16)
tf.cast(a, tf.double)
<tf.Tensor: id=44, shape=(), dtype=float64, numpy=3.140625>
进行类型转换时,需要保证转换操作的合法性,例如将高精度的张量转换为低精度的张量
时,可能发生数据溢出隐患:
a = tf.constant(123456789, dtype=tf.int32)
tf.cast(a, tf.int16)
<tf.Tensor: id=38, shape=(), dtype=int16, numpy=-13035>
布尔型与整形之间相互转换也是合法的,是比较常见的操作:
a = tf.constant([True, False])
tf.cast(a, tf.int32)
<tf.Tensor: id=48, shape=(2,), dtype=int32, numpy=array([1, 0])>
一般默认 0 表示 False,1 表示 True,在 TensorFlow 中,将非 0 数字都视为 True:
a = tf.constant([-1, 0, 1, 2])
tf.cast(a, tf.bool)
<tf.Tensor: id=51, shape=(4,), dtype=bool, numpy=array([ True, False, True,True])>
为了区分需要计算梯度信息的张量与不需要计算梯度信息的张量,TensorFlow 增加了一种专门的数据类型来支持梯度信息的记录:tf.Variable
。tf.Variable
类型在普通的张量类型基础上添加了 name
,trainable
等属性来支持计算图的构建。由于梯度运算会消耗大量的计算资源,而且会自动更新相关参数,对于不需要的优化的张量,如神经网络的输入 X,不需要通过 tf.Variable
封装;相反,对于需要计算梯度并优化的张量,如神经网络层的W和,需要通过 tf.Variable 包裹以便 TensorFlow 跟踪相关梯度信息。
通过 tf.Variable()
函数可以将普通张量转换为待优化张量:
a = tf.constant([-1, 0, 1, 2])
aa = tf.Variable(a)
aa.name, aa.trainable
('Variable:0', True)
其中张量的 name 和 trainable 属性是 Variable 特有的属性,name 属性用于命名计算图中的变量,这套命名体系是 TensorFlow 内部维护的,一般不需要用户关注 name 属性;trainable表征当前张量是否需要被优化,创建 Variable 对象是默认启用优化标志,可以设置trainable=False 来设置张量不需要优化。
除了通过普通张量方式创建 Variable,也可以直接创建:
a = tf.Variable([[1,2],[3,4]])
<tf.Variable 'Variable:0' shape=(2, 2) dtype=int32, numpy=array([[1, 2],[3, 4]])>
待优化张量可看做普通张量的特殊类型,普通张量也可以通过 GradientTape.watch()
方法临时加入跟踪梯度信息的列表。
在 TensorFlow
中,可以通过多种方式创建张量,如从 Python List 对象创建,从Numpy 数组创建,或者创建采样自某种已知分布的张量等。
Numpy Array 数组和 Python List 是 Python 程序中间非常重要的数据载体容器,很多数据都是通过 Python 语言将数据加载至 Array 或者 List 容器,再转换到 Tensor 类型,通过TensorFlow 运算处理后导出到 Array 或者 List 容器,方便其他模块调用。
通过 tf.convert_to_tensor
可以创建新 Tensor,并将保存在 Python List 对象或者 Numpy Array 对象中的数据导入到新 Tensor 中:
tf.convert_to_tensor([1,2.])
<tf.Tensor: id=86, shape=(2,), dtype=float32, numpy=array([1., 2.],dtype=float32)>
tf.convert_to_tensor(np.array([[1,2.],[3,4]]))
<tf.Tensor: id=88, shape=(2, 2), dtype=float64, numpy=array([[1., 2.], [3., 4.]])>
需要注意的是,Numpy 中浮点数数组默认使用 64-Bit 精度保存数据,转换到 Tensor 类型时精度为 tf.float64,可以在需要的时候转换为 tf.float32 类型。
实际上,tf.constant()
和 tf.convert_to_tensor()
都能够自动的把 Numpy 数组或者 PythonList 数据类型转化为 Tensor 类型,这两个 API 命名来自 TensorFlow 1.x 的命名习惯,在TensorFlow 2 中函数的名字并不是很贴切,使用其一即可.
将张量创建为全 0 或者全 1 数据是非常常见的张量初始化手段。考虑线性变换 = + ,将权值矩阵 W 初始化为全 1 矩阵,偏置 b 初始化为全 0 向量,此时线性变化层输出 = ,是一种比较好的层初始化状态。
通过 tf.zeros()
和 tf.ones()
即可创建任意形状全 0 或全 1 的张量。例如,创建为 0 和为 1 的标量张量:
tf.zeros([])
tf.ones([])
(<tf.Tensor: id=90, shape=(), dtype=float32, numpy=0.0>,
<tf.Tensor: id=91, shape=(), dtype=float32, numpy=1.0>)
创建全 0 和全 1 的向量:
tf.zeros([1]),tf.ones([1])
(<tf.Tensor: id=96, shape=(1,), dtype=float32, numpy=array([0.],dtype=float32)>,
<tf.Tensor: id=99, shape=(1,), dtype=float32, numpy=array([1.],dtype=float32)>)
创建全 0 的矩阵:
tf.zeros([2,2])
<tf.Tensor: id=104, shape=(2, 2), dtype=float32, numpy=array([[0., 0.],[0., 0.]],dtype=float32)>
创建全 1 的矩阵:
tf.ones([3,2])
<tf.Tensor: id=108, shape=(3, 2), dtype=float32, numpy=array([[1., 1.],[1., 1.],[1., 1.]], dtype=float32)>
通过 tf.zeros_like
, tf.ones_like
可以方便地新建与某个张量 shape 一致,内容全 0 或全 1
的张量。
例如,创建与张量 a 形状一样的全 0 张量:
a = tf.ones([2,3])
tf.zeros_like(a)
<tf.Tensor: id=113, shape=(2, 3), dtype=float32, numpy=array([[0., 0., 0.],[0., 0., 0.]], dtype=float32)>
创建与张量 a 形状一样的全 1 张量:
a = tf.zeros([3,2])
tf.ones_like(a)
<tf.Tensor: id=120, shape=(3, 2), dtype=float32, numpy=array([[1., 1.],[1., 1.],[1., 1.]], dtype=float32)>
tf.*_like 是一个便捷函数,可以通过 tf.zeros(a.shape)等方式实现。
除了初始化为全 0,或全 1 的张量之外,有时也需要全部初始化为某个自定义数值的张量,比如将张量的数值全部初始化为-1 等。
通过tf.fill(shape, value)
可以创建全为自定义数值 value 的张量。
例如,创建元素为-1
的标量:
tf.fill([], -1)
<tf.Tensor: id=124, shape=(), dtype=int32, numpy=-1>
创建所有元素为-1 的向量:
tf.fill([1], -1)
<tf.Tensor: id=128, shape=(1,), dtype=int32, numpy=array([-1])>
创建所有元素为 99 的矩阵:
tf.fill([2,2], 99)
<tf.Tensor: id=136, shape=(2, 2), dtype=int32, numpy=array([[99, 99],[99, 99]])>
正态分布(Normal Distribution,或 Gaussian Distribution)和均匀分布(UniformDistribution)是最常见的分布之一,创建采样自这 2 种分布的张量非常有用,比如在卷积神经网络中,卷积核张量 W 初始化为正态分布有利于网络的训练;在对抗生成网络中,隐藏变量 z 一般采样自均匀分布。
通过 tf.random.normal(shape, mean=0.0, stddev=1.0)
可以创建形状为 shape,均值为mean,标准差为 stddev 的正态分布(, 2)。
例如,创建均值为 0,标准差为 1的正太分布:
tf.random.normal([2,2])
<tf.Tensor: id=143, shape=(2, 2), dtype=float32, numpy=array([[-0.4307344 , 0.44147003],[-0.6563149 , -0.30100572]], dtype=float32)>
创建均值为 1,标准差为 2 的正太分布:
tf.random.normal([2,2], mean=1,stddev=2)
<tf.Tensor: id=150, shape=(2, 2), dtype=float32, numpy=array([[-2.2687864, -0.7248812],[ 1.2752185, 2.8625617]], dtype=float32)>
通过 tf.random.uniform(shape, minval=0, maxval=None, dtype=tf.float32)
可以创建采样自[, ]区间的均匀分布的张量。
例如创建采样自区间[0,1],shape 为[2,2]的矩阵:
tf.random.uniform([2,2])
<tf.Tensor: id=158, shape=(2, 2), dtype=float32, numpy=array([[0.65483284, 0.63064325],[0.008816 , 0.81437767]], dtype=float32)>
创建采样自区间[0,10],shape 为[2,2]的矩阵:
tf.random.uniform([2,2],maxval=10)
<tf.Tensor: id=166, shape=(2, 2), dtype=float32, numpy=array([[4.541913 , 0.26521802],[2.578913 , 5.126876 ]], dtype=float32)>
如果需要均匀采样整形类型的数据,必须指定采样区间的最大值 maxval 参数,同时制定数据类型为 tf.int*型:
tf.random.uniform([2,2],maxval=100,dtype=tf.int32)
<tf.Tensor: id=171, shape=(2, 2), dtype=int32, numpy=array([[61, 21],[95, 75]])>
在循环计算或者对张量进行索引时,经常需要创建一段连续的整形序列,可以通过tf.range()
函数实现。
tf.range(limit, delta=1)
可以创建[0,)之间,步长为 delta 的整形序列,不包含 limit 本身。
例如,创建 0~9,步长为 1 的整形序列:
tf.range(10)
<tf.Tensor: id=180, shape=(10,), dtype=int32, numpy=array([0, 1, 2, 3, 4, 5,6, 7, 8, 9])>
创建 0~9,步长为 2 的整形序列:
tf.range(10,delta=2)
<tf.Tensor: id=185, shape=(5,), dtype=int32, numpy=array([0, 2, 4, 6, 8])>
通过 tf.range(start, limit, delta=1)
可以创建[, ),步长为 delta 的序列,不包含 limit
本身:
tf.range(1,10,delta=2)
<tf.Tensor: id=190, shape=(5,), dtype=int32, numpy=array([1, 3, 5, 7, 9])>
在介绍完张量的相关属性和创建方式后,我们将介绍每种维度下张量的典型应用,让读者在看到每种张量时,能够直观地联想到它主要的物理意义和用途,对后续张量的维度变换等一系列抽象操作的学习打下基础。本节在介绍典型应用时不可避免地会提及后续将要学习的网络模型或算法,学习时不需要完全理解,有初步印象即可。
在 TensorFlow 中,标量最容易理解,它就是一个简单的数字,维度数为 0,shape 为[]。标量的典型用途之一是误差值的表示、各种测量指标的表示,比如准确度(Accuracy,acc),精度(Precision)和召回率(Recall)等。
考虑某个模型的训练曲线,如图 4.1 所示,横坐标为训练 Batch 步数 Step,纵坐标分别为误差变化趋势(图 4.1(a))和准确度变化趋势曲线(图 4.1(b)),其中损失值 loss 和准确度均由张量计算产生,类型为标量。
以均方差误函数为例,经过 tf.keras.losses.mse
(或 tf.keras.losses.MSE
)返回每个样本上的误差值,最后取误差的均值作为当前 batch 的误差,它是一个标量:
out = tf.random.uniform([4,10]) #随机模拟网络输出
y = tf.constant([2,3,2,0]) # 随机构造样本真实标签
y = tf.one_hot(y, depth=10) # one-hot 编码
loss = tf.keras.losses.mse(y, out) # 计算每个样本的 MSE
loss = tf.reduce_mean(loss) # 平均 MSE
print(loss)
tf.Tensor(0.19950335, shape=(), dtype=float32)
向量是一种非常常见的数据载体,如在全连接层和卷积神经网络层中,偏置张量就使用向量来表示。如图 4.2 所示,每个全连接层的输出节点都添加了一个偏置值,把所有输出节点的偏置表示成向量形式: = [1, 2]
考虑 2 个输出节点的网络层,我们创建长度为 2 的偏置向量,并累加在每个输出节点上:
# z=wx,模拟获得激活函数的输入 z
z = tf.random.normal([4,2])
b = tf.zeros([2]) # 模拟偏置向量
z = z + b # 累加偏置
<tf.Tensor: id=245, shape=(4, 2), dtype=float32, numpy=
array([[ 0.6941646 , 0.4764454 ],
[-0.34862405, -0.26460952],
[ 1.5081744 , -0.6493869 ],
[-0.26224667, -0.78742725]], dtype=float32)>
注意到这里 shape 为[4,2]的和 shape 为[2]的张量可以直接相加,这是为什么呢?让我们在 Broadcasting
一节为大家揭秘。
通过高层接口类 Dense()
方式创建的网络层,张量 W 和存储在类的内部,由类自动创建并管理。可以通过全连接层的 bias 成员变量查看偏置变量,例如创建输入节点数为 4,输出节点数为 3 的线性层网络,那么它的偏置向量 b 的长度应为 3:
fc = layers.Dense(3) # 创建一层 Wx+b,输出节点为 3
# 通过 build 函数创建 W,b 张量,输入节点为 4
fc.build(input_shape=(2,4))
fc.bias # 查看偏置
<tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.],
dtype=float32)>
可以看到,类的偏置成员 bias 初始化为全 0,这也是偏置的默认初始化方案。
矩阵也是非常常见的张量类型,比如全连接层的批量输入 = [, d i n d_{in} din],其中表示输入样本的个数,即 batch size, d i n d_{in} din表示输入特征的长度。比如特征长度为 4,一共包含 2 个样本的输入可以表示为矩阵:
x = tf.random.normal([2,4])
令全连接层的输出节点数为 3,则它的权值张量 W 的 shape 为[4,3]:
w=tf.ones([4,3])
b=tf.zeros([3])
o =tf.matmul(x, w)+b
print(o)
<tf.Tensor: id=291, shape=(2, 3), dtype=float32, numpy=
array([[ 2.3506963, 2.3506963, 2.3506963],
[-1.1724043, -1.1724043, -1.1724043]], dtype=float32)>
其中 X,W 张量均是矩阵。x*w+b 网络层称为线性层,在 TensorFlow 中可以通过 Dense类直接实现,Dense 层也称为全连接层。我们通过 Dense 类创建输入 4 个节点,输出 3 个节点的网络层,可以通过全连接层的 kernel 成员名查看其权值矩阵 W:
fc = layers.Dense(3) # 定义全连接层的输出节点为 3
fc.build(input_shape=(2,4)) # 定义全连接层的输入节点为 4
fc.kernel
<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=
array([[ 0.06468129, -0.5146048 , -0.12036425],
[ 0.71618867, -0.01442951, -0.5891943 ],
[-0.03011459, 0.578704 , 0.7245046 ],
[ 0.73894167, -0.21171576, 0.4820758 ]], dtype=float32)>
三维的张量一个典型应用是表示序列信号,它的格式是
X = [ b , \boldsymbol{X}=[b, X=[b, sequence len, feature len ] ] ]
其中表示序列信号的数量,sequence len 表示序列信号在时间维度上的采样点数,featurelen 表示每个点的特征长度。
考虑自然语言处理中句子的表示,如评价句子的是否为正面情绪的情感分类任务网络,如图 4.3 所示。为了能够方便字符串被神经网络处理,一般将单词通过嵌入层(Embedding Layer)编码为固定长度的向量,比如“a”编码为某个长度 3 的向量,那么 2 个等长(单词数为 5)的句子序列可以表示为 shape 为[2,5,3]的 3 维张量,其中 2 表示句子个数,5 表示单词数量,3 表示单词向量的长度:
# 自动加载 IMDB 电影评价数据集
(x_train,y_train),(x_test,y_test)=keras.datasets.imdb.load_data(num_words=10000)
# 将句子填充、截断为等长 80 个单词的句子
x_train = keras.preprocessing.sequence.pad_sequences(x_train,maxlen=80)
x_train.shape
可以看到 x_train 张量的 shape 为[25000,80],其中 25000 表示句子个数,80 表示每个句子共 80 个单词,每个单词使用数字编码方式。我们通过 layers.Embedding
层将数字编码的单词转换为长度为 100 个词向量:
# 创建词向量 Embedding 层类
embedding=layers.Embedding(10000, 100)
# 将数字编码的单词转换为词向量
out = embedding(x_train)
out.shape
TensorShape([25000, 80, 100])
可以看到,经过 Embedding 层编码后,句子张量的 shape 变为[25000,80,100],其中 100 表示每个单词编码为长度 100 的向量。
对于特征长度为 1 的序列信号,比如商品价格在 60 天内的变化曲线,只需要一个标量即可表示商品的价格,因此 2 件商品的价格变化趋势可以使用 shape 为[2,60]的张量表示。为了方便统一格式,也将价格变化趋势表达为 shape 为 [2,60,1]的张量,其中的 1 表示特征长度为 1。
我们这里只讨论 3/4 维张量,大于 4 维的张量一般应用的比较少,如在元学习(metalearning)中会采用 5 维的张量表示方法,理解方法与 3/4 维张量类似。
4维张量在卷积神经网络中应用的非常广泛,它用于保存特征图(Feature maps)数据,格式一般定义为
[ b , h , w , c ] [b, h, w, c] [b,h,w,c]
其中表示输入的数量,h/w分布表示特征图的高宽,表示特征图的通道数,部分深度学习框架也会使用[, , ℎ, ]格式的特征图张量,例如 PyTorch。图片数据是特征图的一种,对于含有 RGB 3 个通道的彩色图片,每张图片包含了 h 行 w 列像素点,每个点需要 3 个数值表示 RGB 通道的颜色强度,因此一张图片可以表示为[h,w, 3]。如图 4.4 所示,最上层的图片表示原图,它包含了下面 3 个通道的强度信息。
神经网络中一般并行计算多个输入以提高计算效率,故张图片的张量可表示为[, ℎ, w, 3]。
# 创建 32x32 的彩色图片输入,个数为 4
x = tf.random.normal([4,32,32,3])
# 创建卷积神经网络
layer = layers.Conv2D(16,kernel_size=3)
out = layer(x) # 前向计算
out.shape # 输出大小
TensorShape([4, 30, 30, 16])
其中卷积核张量也是 4 维张量,可以通过 kernel 成员变量访问:
layer.kernel.shape
Out[49]: TensorShape([3, 3, 3, 16])
通过索引与切片操作可以提取张量的部分数据,使用频率非常高
在 TensorFlow 中,支持基本的[][]…标准索引方式,也支持通过逗号分隔索引号的索引方式。考虑输入 X 为 4 张 32x32 大小的彩色图片(为了方便演示,大部分张量都使用随机分布模拟产生,后文同),shape 为[4,32,32,3],首先创建张量:
x = tf.random.normal([4,32,32,3])
接下来我们使用索引方式读取张量的部分数据。
❑ 取第 1 张图片的数据:
x[0]
<tf.Tensor: id=379, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 , 1.5301839 , -0.32005513],
[-1.3020388 , 1.7837263 , -1.0747638 ], ...
[-1.1092019 , -1.045254 , -0.4980363 ],
[-0.9099222 , 0.3947732 , -0.10433522]]], dtype=float32)>
❑ 取第 1 张图片的第 2 行:
x[0][1]
<tf.Tensor: id=388, shape=(32, 3), dtype=float32, numpy=
array([[ 4.2904025e-01, 1.0574218e+00, 3.1540772e-01],
[ 1.5800388e+00, -8.1637271e-02, 6.3147342e-01], ...,
[ 2.8893018e-01, 5.8003378e-01, -1.1444757e+00],
[ 9.6100050e-01, -1.0985689e+00, 1.0827581e+00]], dtype=float32)>
❑ 取第 1 张图片,第 2 行,第 3 列的像素:
In [53]: x[0][1][2]
Out[53]:
<tf.Tensor: id=401, shape=(3,), dtype=float32, numpy=array([-0.55954427,
0.14497331, 0.46424514], dtype=float32)>
❑ 取第 3 张图片,第 2 行,第 1 列的像素,B 通道(第 2 个通道)颜色强度值:
x[2][1][0][1]
Out[54]:
<tf.Tensor: id=418, shape=(), dtype=float32, numpy=-0.84922135>
当张量的维度数较高时,使用[][]. . .[]的方式书写不方便,可以采用[,, … , ]的方式索引,它们是等价的。
❑ 取第 2 张图片,第 10 行,第 3 列:
x[1,9,2]
<tf.Tensor: id=436, shape=(3,), dtype=float32, numpy=array([ 1.7487534 , -
0.41491988, -0.2944692 ], dtype=float32)>
通过: :
切片方式可以方便地提取一段数据,其中 start 为开始读取位置的索引,end 为结束读取位置的索引(不包含 end 位),step 为读取步长。
以 shape 为[4,32,32,3]的图片张量为例:
❑ 读取第 2和第3 张图片:
x[1:3]
<tf.Tensor: id=441, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 0.6920027 , 0.18658352, 0.0568333 ],
[ 0.31422952, 0.75933754, 0.26853144],
[ 2.7898 , -0.4284912 , -0.26247284],...
start: end: step切片方式有很多简写方式,其中 start、end、step 3 个参数可以根据需要选择性地省略,全部省略时即::,表示从最开始读取到最末尾,步长为 1,即不跳过任何元素。如 x[0,::]表示读取第 1 张图片的所有行,其中::表示在行维度上读取所有行,它等于x[0]的写法:
x[0,::]
<tf.Tensor: id=446, shape=(32, 32, 3), dtype=float32, numpy=
array([[[ 1.3005302 , 1.5301839 , -0.32005513],
[-1.3020388 , 1.7837263 , -1.0747638 ],
[-1.1230233 , -0.35004002, 0.01514002],
为了更加简洁,::可以简写为单个冒号:,如
x[:,0:28:2,0:28:2,:]
<tf.Tensor: id=451, shape=(4, 14, 14, 3), dtype=float32, numpy=
array([[[[ 1.3005302 , 1.5301839 , -0.32005513],
[-1.1230233 , -0.35004002, 0.01514002],
[ 1.3474811 , 0.639334 , -1.0826371 ],
表示取所有图片,隔行采样,隔列采样,所有通道信息,相当于在图片的高宽各缩放至原来的 50%。
我们来总结start: end: step切片的简写方式,其中从第一个元素读取时 start 可以省略,即 start=0 是可以省略,取到最后一个元素时 end 可以省略,步长为 1 时 step 可以省略,简写方式总结如表格 4.1:
特别地,step 可以为负数,考虑最特殊的一种例子,step = −1时,start: end: −1表示从 start 开始,逆序读取至 end 结束(不包含 end),索引号 ≤ 。考虑一 0~9 简单序列,逆序取到第 1 号元素,不包含第 1 号:
x = tf.range(9)
x[8:0:-1]
<tf.Tensor: id=466, shape=(8,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3,2, 1])>
逆序取全部元素:
x[::-1]
<tf.Tensor: id=471, shape=(9,), dtype=int32, numpy=array([8, 7, 6, 5, 4, 3,2, 1, 0])>
逆序间隔采样:
x[::-2]
<tf.Tensor: id=476, shape=(5,), dtype=int32, numpy=array([8, 6, 4, 2, 0])>
读取每张图片的所有通道,其中行按着逆序隔行采样,列按着逆序隔行采样:
x = tf.random.normal([4,32,32,3])
x[0,::-2,::-2]
<tf.Tensor: id=487, shape=(16, 16, 3), dtype=float32, numpy=
array([[[ 0.63320625, 0.0655185 , 0.19056146],
[-1.0078577 , -0.61400175, 0.61183935],
[ 0.9230892 , -0.6860094 , -0.01580668],
…
当张量的维度数量较多时,不需要采样的维度一般用单冒号:表示采样所有元素,此时有可能出现大量的:出现。继续考虑[4,32,32,3]的图片张量,当需要读取 G 通道上的数据时,前面所有维度全部提取,此时需要写为:
x[:,:,:,1]
<tf.Tensor: id=492, shape=(4, 32, 32), dtype=float32, numpy=
array([[[ 0.575703 , 0.11028383, -0.9950867 , ..., 0.38083118,
-0.11705163, -0.13746642],
...
为了避免出现像[: , : , : ,1]这样出现过多冒号的情况,可以使用⋯符号表示取多个维度上所有的数据,其中维度的数量需根据规则自动推断:当切片方式出现⋯符号时,⋯符号左边的维度将自动对齐到最左边,⋯符号右边的维度将自动对齐到最右边,此时系统再自动推断⋯符号代表的维度数量,它的切片方式总结如表格 4.2:
考虑如下例子:
❑ 读取第 1-2 张图片的 G/B 通道数据:
In [64]: x[0:2,...,1:]
Out[64]:
<tf.Tensor: id=497, shape=(2, 32, 32, 2), dtype=float32, numpy=
array([[[[ 0.575703 , 0.8872789 ],
[ 0.11028383, -0.27128693],
[-0.9950867 , -1.7737272 ],
...
❑ 读取最后 2 张图片:
x[2:,...]
<tf.Tensor: id=502, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-8.10753584e-01, 1.10984087e+00, 2.71821529e-01],
[-6.10031188e-01, -6.47952318e-01, -4.07003373e-01],
[ 4.62206364e-01, -1.03655539e-01, -1.18086267e+00],
...
❑ 读取 R/G 通道数据:
x[...,:2]
<tf.Tensor: id=507, shape=(4, 32, 32, 2), dtype=float32, numpy=
array([[[[-1.26881 , 0.575703 ],
[ 0.98697686, 0.11028383],
[-0.66420585, -0.9950867 ],
...
张量的索引与切片方式多种多样,尤其是切片操作,初学者容易犯迷糊。但其实本质上切片操作只有: : 这一种基本形式,通过这种基本形式有目的地省略掉默认参数,从而衍生出多种简写方法,这也是很好理解的。它衍生的简写形式熟练后一看就能推测出省略掉的信息,书写起来也更方便快捷。由于深度学习一般处理的维度数在 4 维以内,⋯操作符完全可以用:符号代替,因此理解了这些就会发现张量切片操作并不复杂.
在神经网络运算过程中,维度变换是最核心的张量操作,通过维度变换可以将数据任意地切换形式,满足不同场合的运算需求。
那么为什么需要维度变换呢?考虑线性层的批量形式:
Y = X @ W + b Y=X @ W+b Y=X@W+b
其中 X 包含了 2 个样本,每个样本的特征长度为 4,X 的 shape 为[2,4]。线性层的输出为 3个节点,即 W 的 shape 定义为[4,3],偏置的 shape 定义为[3]。那么X@W的运算张量shape 为[2,3],需要叠加上 shape 为[3]的偏置。不同 shape 的 2 个张量怎么直接相加呢?
回到我们设计偏置的初衷,我们给每个层的每个输出节点添加一个偏置,这个偏置数据是对所有的样本都是共享的,换言之,每个样本都应该累加上同样的偏置向量,如图4.5 所示:
因此,对于 2 个样本的输入 X,我们需要将 shape 为[3]的偏置
b = [ b 0 b 1 b 2 ] \boldsymbol{b}=\left[\begin{array}{l} b_{0} \\ b_{1} \\ b_{2} \end{array}\right] b=⎣⎡b0b1b2⎦⎤
按样本数量复制 1 份,变成矩阵形式
B ′ = [ b 0 b 1 b 2 b 0 b 1 b 2 ] B^{\prime}=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right] B′=[b0b0b1b1b2b2]
通过与X′ = X@W
X ′ = [ x 00 ′ x 01 ′ x 02 ′ x 10 ′ x 11 ′ x 12 ′ ] \mathrm{X}^{\prime}=\left[\begin{array}{lll} x_{00}^{\prime} & x_{01}^{\prime} & x_{02}^{\prime} \\ x_{10}^{\prime} & x_{11}^{\prime} & x_{12}^{\prime} \end{array}\right] X′=[x00′x10′x01′x11′x02′x12′]
相加,此时X′与 shape 相同,满足矩阵相加的数学条件:
Y = X ′ + B ′ = [ x 00 ′ x 01 ′ x 02 ′ x 10 ′ x 11 ′ x 12 ′ ] + [ b 0 b 1 b 2 b 0 b 1 b 2 ] \mathrm{Y}=\mathrm{X}^{\prime}+\mathrm{B}^{\prime}=\left[\begin{array}{lll} x_{00}^{\prime} & x_{01}^{\prime} & x_{02}^{\prime} \\ x_{10}^{\prime} & x_{11}^{\prime} & x_{12}^{\prime} \end{array}\right]+\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right] Y=X′+B′=[x00′x10′x01′x11′x02′x12′]+[b0b0b1b1b2b2]
通过这种方式,既满足了数学上矩阵相加需要 shape 一致的条件,又达到了给每个输入样本的输出节共享偏置的逻辑。为了实现这种运算方式,我们将插入一个新的维度,并把它定义为 batch 维度,然后在 batch 维度将数据复制 1 份,得到变换后的B′,新的 shape 为[2,3]。
算法的每个模块对于数据张量的格式有不同的逻辑要求,当现有的数据格式不满足算法要求时,需要通过维度变换将数据调整为正确的格式。这就是维度变换的功能。
基本的维度变换包含了改变视图 reshape
,插入新维度 expand_dims
,删除维度squeeze
,交换维度 transpose
,复制数据 tile
等
在介绍改变视图操作之前,我们先来认识一下张量的存储和视图(View)的概念。张量的视图就是我们理解张量的方式,比如 shape 为[2,4,4,3]的张量 A,我们从逻辑上可以理解为 2 张图片,每张图片 4 行 4 列,每个位置有 RGB 3 个通道的数据;张量的存储体现在张量在内存上保存为一段连续的内存区域,对于同样的存储,我们可以有不同的理解方式,比如上述 A,我们可以在不改变张量的存储下,将张量 A 理解为 2 个样本,每个样本的特征为长度 48 的向量。这就是存储与视图的关系。
我们通过 tf.range()模拟生成 x 的数据:
x=tf.range(96)
x=tf.reshape(x,[2,4,4,3])
<tf.Tensor: id=11, shape=(2, 4, 4, 3), dtype=int32, numpy=
array([[[[ 0, 1, 2],
[ 3, 4, 5],
[ 6, 7, 8],
[ 9, 10, 11]],…
在存储数据时,内存并不支持这个维度层级概念,只能以平铺方式按序写入内存,因此这种层级关系需要人为管理,也就是说,每个张量的存储顺序需要人为跟踪。为了方便表达,我们把张量 shape 中相对靠左侧的维度叫做大维度,shape 中相对靠右侧的维度叫做小维度,比如[2,4,4,3]的张量中,图片数量维度与通道数量相比,图片数量叫做大维度,通道数叫做小维度。在优先写入小维度的设定下,上述张量的内存布局为
数据在创建时按着初始的维度顺序写入,改变张量的视图仅仅是改变了张量的理解方式,并不会改变张量的存储顺序,这在一定程度上是从计算效率考虑的,大量数据的写入操作会消耗较多的计算资源。改变视图操作在提供便捷性的同时,也会带来很多逻辑隐患,这主要的原因是张量的视图与存储不同步造成的。我们先介绍合法的视图变换操作,再介绍不合法的视图变换。
比如张量按着初始视图[, ℎ, w, ]写入的内存布局,我们改变初始视图[, ℎ, w, ]的理解方式,它可以有多种合法理解方式:
❑ [, ℎ ∗w , ] 张量理解为 b 张图片,h * w 个像素点,c 个通道
❑ [, ℎ, w∗ ] 张量理解为 b 张图片,h 行,每行的特征长度为 w * c
❑ [, ℎ ∗ w∗ ] 张量理解为 b 张图片,每张图片的特征长度为 h * w * c
从语法上来说,视图变换只需要满足新视图的元素总量与内存区域大小相等即可,即新视图的元素数量等于
b ∗ h ∗ w ∗ c b∗ h ∗w ∗ c b∗h∗w∗c
正是由于视图的设计约束很少,完全由用户定义,使得在改变视图时容易出现逻辑隐患。
现在我们来考虑不合法的视图变换。例如,如果定义新视图为[,w , ℎ, ],[, , ℎ ∗w ]或者[, , ℎ, w]等时,与张量的存储顺序相悖,如果不同步更新张量的存储顺序,那么恢复出的数据将与新视图不一致,从而导致数据错乱。
为了能够正确恢复出数据,必须保证张量的存储顺序与新视图的维度顺序一致,例如根据图片数量-行-列-通道初始视图保存的张量,按照图片数量-行-列-通道( − ℎ −w − )的顺序可以获得合法数据。如果按着图片数量-像素-通道( b− h ∗ w − c)的方式恢复视图,也能得到合法的数据。但是如果按着图片数量-通道-像素( b− c − h ∗ w)的方式恢复数据,由于内存布局是按着图片数量-行-列-通道的顺序,视图维度与存储维度顺序相悖,提取的数据将是错乱的。
改变视图是神经网络中非常常见的操作,可以通过串联多个 Reshape
操作来实现复杂逻辑,但是在通过 Reshape 改变视图时,必须始终记住张量的存储顺序,新视图的维度顺序不能与存储顺序相悖,否则需要通过交换维度操作将存储顺序同步过来。
举个例子,对于 shape 为[4,32,32,3]的图片数据,通过 Reshape 操作将 shape 调整为[4,1024,3],此时视图的维度顺序为 − − ,张量的存储顺序为[, ℎ, w, ]。可以将[4,1024,3]恢复为
❑ [, ℎ, w, ] = [4,32,32,3]时,新视图的维度顺序与存储顺序无冲突,可以恢复出无逻辑问题的数据
❑ [, w, ℎ, ] = [4,32,32,3]时,新视图的维度顺序与存储顺序冲突
❑ [ℎ ∗w ∗ , ] = [3072,4]时,新视图的维度顺序与存储顺序冲突
在 TensorFlow 中,可以通过张量的 ndim
和 shape
成员属性获得张量的维度数和形状:
x.ndim,x.shape
(4, TensorShape([2, 4, 4, 3]))
通过 tf.reshape(x, new_shape)
,可以将张量的视图任意的合法改变:
tf.reshape(x,[2,-1])
<tf.Tensor: id=520, shape=(2, 48), dtype=int32, numpy=
array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,…
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]])>
其中的参数-1 表示当前轴上长度需要根据视图总元素不变的法则自动推导,从而方便用户书写。比如,上面的-1 可以推导为
2 ∗ 4 ∗ 4 ∗ 3 2 = 48 \frac{2 * 4 * 4 * 3}{2}=48 22∗4∗4∗3=48
再次改变数据的视图为[2,4,12]:
tf.reshape(x,[2,4,12])
<tf.Tensor: id=523, shape=(2, 4, 12), dtype=int32, numpy=
array([[[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],…
[36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]],
[[48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59], …
[84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]]])>
再次改变数据的视图为[2,16,3]:
tf.reshape(x,[2,-1,3])
<tf.Tensor: id=526, shape=(2, 16, 3), dtype=int32, numpy=
array([[[ 0, 1, 2], …
[45, 46, 47]],
[[48, 49, 50],…
[93, 94, 95]]])>
通过上述的一系列连续变换视图操作时需要意识到,张量的存储顺序始终没有改变,数据在内存中仍然是按着初始写入的顺序0,1,2, … ,95保存的。
增加维度 增加一个长度为 1 的维度相当于给原有的数据增加一个新维度的概念,维度长度为 1,故数据并不需要改变,仅仅是改变数据的理解方式,因此它其实可以理解为改变视图的一种特殊方式。
考虑一个具体例子,一张 28x28 灰度图片的数据保存为 shape 为[28,28]的张量,在末尾给张量增加一新维度,定义为为通道数维度,此时张量的 shape 变为[28,28,1]:
x = tf.random.uniform([28,28],maxval=10,dtype=tf.int32)
<tf.Tensor: id=552, shape=(28, 28), dtype=int32, numpy=
array([[4, 5, 7, 6, 3, 0, 3, 1, 1, 9, 7, 7, 3, 1, 2, 4, 1, 1, 9, 8, 6, 6,
4, 9, 9, 4, 6, 0],…
通过 tf.expand_dims(x, axis)
可在指定的 axis 轴前可以插入一个新的维度:
x = tf.expand_dims(x,axis=2)
<tf.Tensor: id=555, shape=(28, 28, 1), dtype=int32, numpy=
array([[[4],
[5],
[7],
[6],
[3],…
可以看到,插入一个新维度后,数据的存储顺序并没有改变,依然按着 4,5,7,6,3,0,…的顺序保存,仅仅是在插入一个新的维度后,改变了数据的视图。
同样的方法,我们可以在最前面插入一个新的维度,并命名为图片数量维度,长度为1,此时张量的 shape 变为[1,28,28,1]。
x = tf.expand_dims(x,axis=0)
<tf.Tensor: id=558, shape=(1, 28, 28), dtype=int32, numpy=
array([[[4, 5, 7, 6, 3, 0, 3, 1, 1, 9, 7, 7, 3, 1, 2, 4, 1, 1, 9, 8, 6,
6, 4, 9, 9, 4, 6, 0],
[5, 8, 6, 3, 6, 4, 3, 0, 5, 9, 0, 5, 4, 6, 4, 9, 4, 4, 3, 0, 6,
9, 3, 7, 4, 2, 8, 9],…
需要注意的是,tf.expand_dims
的 axis 为正时,表示在当前维度之前插入一个新维度;为负时,表示当前维度之后插入一个新的维度。以[, ℎ, w, ]张量为例,不同 axis 参数的实际插入位置如下图 4.6 所示:
删除维度 是增加维度的逆操作,与增加维度一样,删除维度只能删除长度为 1 的维度,也不会改变张量的存储。继续考虑增加维度后 shape 为[1,28,28,1]的例子,如果希望将图片数量维度删除,可以通过 tf.squeeze(x, axis)
函数,axis 参数为待删除的维度的索引号,图片数量的维度轴 axis=0:
x = tf.squeeze(x, axis=0)
<tf.Tensor: id=586, shape=(28, 28, 1), dtype=int32, numpy=
array([[[8],
[2],
[2],
[0],…
继续删除通道数维度,由于已经删除了图片数量维度,此时的 x 的 shape 为[28,28,1],因此删除通道数维度时指定 axis=2:
x = tf.squeeze(x, axis=2)
<tf.Tensor: id=588, shape=(28, 28), dtype=int32, numpy=
array([[8, 2, 2, 0, 7, 0, 1, 4, 9, 1, 7, 4, 8, 2, 7, 4, 8, 2, 9, 8, 8, 0,
9, 9, 7, 5, 9, 7],
[3, 4, 9, 9, 0, 6, 5, 7, 1, 9, 9, 1, 2, 7, 2, 7, 5, 3, 3, 7, 2, 4,
5, 2, 7, 3, 8, 0],…
如果不指定维度参数 axis,即 ·tf.squeeze(x)·,那么他会默认删除所有长度为 1 的维度:
x = tf.random.uniform([1,28,28,1],maxval=10,dtype=tf.int32)
tf.squeeze(x)
<tf.Tensor: id=594, shape=(28, 28), dtype=int32, numpy=
array([[9, 1, 4, 6, 4, 9, 0, 0, 1, 4, 0, 8, 5, 2, 5, 0, 0, 8, 9, 4, 5, 0,
1, 1, 4, 3, 9, 9],…
改变视图、增删维度都不会影响张量的存储。在实现算法逻辑时,在保持维度顺序不变的条件下,仅仅改变张量的理解方式是不够的,有时需要直接调整的存储顺序,即交换维度(Transpose)。通过交换维度,改变了张量的存储顺序,同时也改变了张量的视图。
交换维度操作是非常常见的,比如在 TensorFlow 中,图片张量的默认存储格式是通道后行格式:[, ℎ, w, ],但是部分库的图片格式是通道先行:[, , ℎ, w],因此需要完成[, ℎ, w, ]到[, , ℎ,w ]维度交换运算。
我们以[, ℎ, w, ]转换到[, , ℎ,w ]为例,介绍如何使用 tf.transpose(x, perm)
函数完成维度交换操作,其中 perm
表示新维度的顺序 List。考虑图片张量 shape 为[2,32,32,3],图片数量、行、列、通道数的维度索引分别为 0,1,2,3,如果需要交换为[, , ℎ, w]格式,则新维度的排序为图片数量、通道数、行、列,对应的索引号为[0,3,1,2],实现如下:
x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,3,1,2])
<tf.Tensor: id=603, shape=(2, 3, 32, 32), dtype=float32, numpy=
array([[[[-1.93072677e+00, -4.80163872e-01, -8.85614634e-01, ...,
1.49124235e-01, 1.16427064e+00, -1.47740364e+00],
[-1.94761145e+00, 7.26879001e-01, -4.41877693e-01, ...
如果希望将[, ℎ, w, ]交换为[, w, ℎ, ],即将行列维度互换,则新维度索引为[0,2,1,3]:
x = tf.random.normal([2,32,32,3])
tf.transpose(x,perm=[0,2,1,3])
<tf.Tensor: id=612, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[ 2.1266546 , -0.64206547, 0.01311932],
[ 0.918484 , 0.9528751 , 1.1346699 ],
...,
需要注意的是,通过 tf.transpose
完成维度交换后,张量的存储顺序已经改变,视图也随之改变,后续的所有操作必须基于新的存续顺序进行。
当通过增加维度操作插入新维度后,可能希望在新的维度上面复制若干份数据,满足后续算法的格式要求。
考虑 = @ + 的例子,偏置插入新维度后,需要在新维度上复制 batch size 份数据,将 shape 变为与@一致后,才能完成张量相加运算。可以通过tf.tile(x, multiples)
函数完成数据在指定维度上的复制操作,multiples 分别指定了每个维度上面的复制倍数,对应位置为 1 表明不复制,为 2 表明新长度为原来的长度的 2 倍,即数据复制一份,以此类推。
以输入为[2,4],输出为 3 个节点线性变换层为例,偏置定义为:
b = [ b 0 b 1 b 2 ] \boldsymbol{b}=\left[\begin{array}{l} b_{0} \\ b_{1} \\ b_{2} \end{array}\right] b=⎣⎡b0b1b2⎦⎤
通过 tf.expand_dims(b,axis=0)
插入新维度:样本数量维度
b = [ b 0 b 1 b 2 ] ] \left.\boldsymbol{b}=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \end{array}\right]\right] b=[b0b1b2]]
此时的 shape 变为[1,3],我们需要在 axis=0 图片数量维度上根据输入样本的数量复制若干次,这里的 batch size 为 2,变为矩阵 B:
B = [ b 0 b 1 b 2 b 0 b 1 b 2 ] B=\left[\begin{array}{lll} b_{0} & b_{1} & b_{2} \\ b_{0} & b_{1} & b_{2} \end{array}\right] B=[b0b0b1b1b2b2]
通过 tf.tile(b, multiples=[2,1])
即可在 axis=0 维度复制 1 次,在 axis=1 维度不复制。首先插入新的维度:
b = tf.constant([1,2])
b = tf.expand_dims(b, axis=0)
b
<tf.Tensor: id=645, shape=(1, 2), dtype=int32, numpy=array([[1, 2]])>
在 batch 维度上复制数据 1 份:
b = tf.tile(b, multiples=[2,1])
<tf.Tensor: id=648, shape=(2, 2), dtype=int32, numpy=
array([[1, 2],
[1, 2]])>
此时 B 的 shape 变为[2,3],可以直接与X@W进行相加运算。
考虑另一个例子,输入 x 为 2 行 2 列的矩阵:
x = tf.range(4)
x=tf.reshape(x,[2,2])
<tf.Tensor: id=655, shape=(2, 2), dtype=int32, numpy=
array([[0, 1],
[2, 3]])>
首先在列维度复制 1 份数据:
x = tf.tile(x,multiples=[1,2])
<tf.Tensor: id=658, shape=(2, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
[2, 3, 2, 3]])>
然后在行维度复制 1 份数据:
x = tf.tile(x,multiples=[2,1])
<tf.Tensor: id=672, shape=(4, 4), dtype=int32, numpy=
array([[0, 1, 0, 1],
[2, 3, 2, 3],
[0, 1, 0, 1],
[2, 3, 2, 3]])>
经过 2 个维度上的复制运算后,可以看到数据的变化过程,shape 也变为原来的 2 倍。
需要注意的是,tf.tile
会创建一个新的张量来保存复制后的张量,由于复制操作涉及到大量数据的读写 IO 运算,计算代价相对较高。神经网络中不同 shape 之间的运算操作十分频繁,那么有没有轻量级的复制操作呢?这就是接下来要介绍的 Broadcasting 操作。
Broadcasting
也叫广播机制(自动扩展也许更合适),它是一种轻量级张量复制的手段,在逻辑上扩展张量数据的形状,但是只要在需要时才会执行实际存储复制操作。对于大部分场景,Broadcasting
机制都能通过优化手段避免实际复制数据而完成逻辑运算,从而相对于 tf.tile
函数,减少了大量计算代价。
对于所有长度为 1 的维度,Broadcasting 的效果和 tf.tile 一样,都能在此维度上逻辑复制数据若干份,区别在于 tf.tile 会创建一个新的张量,执行复制 IO 操作,并保存复制后的张量数据,Broadcasting 并不会立即复制数据,它会逻辑上改变张量的形状,使得视图上变成了复制后的形状。
Broadcasting 会通过深度学习框架的优化手段避免实际复制数据而完成逻辑运算,至于怎么实现的用户不必关系,对于用户来说,Broadcasting 和 tf.tile 复制的最终效果是一样的,操作对用户透明,但是 Broadcasting 机制节省了大量计算资源,建议在运算过程中尽可能地利用 Broadcasting 提高计算效率。
继续考虑上述的Y = X@W +
的例子,X@W的 shape 为[2,3],的 shape 为[3],我们可以通过结合 tf.expand_dims
和 tf.tile
完成实际复制数据运算,将变换为[2,3],然后与X@W完成相加。但实际上,我们直接将 shape 为[2,3]与[3]的相加:
x = tf.random.normal([2,4])
w = tf.random.normal([4,3])
b = tf.random.normal([3])
y = x@w+b
上述加法并没有发生逻辑错误,那么它是怎么实现的呢?这是因为它自动调用 Broadcasting函数 tf.broadcast_to(x, new_shape)
,将 2 者 shape 扩张为相同的[2,3],即上式可以等效为:
y = x@w + tf.broadcast_to(b,[2,3])
也就是说,操作符+在遇到 shape 不一致的 2 个张量时,会自动考虑将 2 个张量Broadcasting 到一致的 shape,然后再调用 tf.add
完成张量相加运算,这也就解释了我们之前一直存在的困惑。通过自动调用 tf.broadcast_to(b, [2,3])
的 Broadcasting 机制,既实现了增加维度、复制数据的目的,又避免实际复制数据的昂贵计算代价,同时书写更加简洁高效。
那么有了Broadcasting 机制后,所有 shape 不一致的张量是不是都可以直接完成运算?很明显,所有的运算都需要在正确逻辑下进行,Broadcasting 机制并不会扰乱正常的计算逻辑,它只会针对于最常见的场景自动完成增加维度并复制数据的功能,提高开发效率和运行效率。这种最常见的场景是什么呢?这就要说到 Broadcasting 设计的核心思想。
Broadcasting 机制的核心思想是普适性,即同一份数据能普遍适合于其他位置。在验证普适性之前,需要将张量 shape 靠右对齐,然后进行普适性判断:对于长度为 1 的维度,默认这个数据普遍适合于当前维度的其他位置;对于不存在的维度,则在增加新维度后默认当前数据也是普适性于新维度的,从而可以扩展为更多维度数、其他长度的张量形状。
考虑 shape 为[ w w w, 1]的张量 A,需要扩展为 shape:[, ℎ, w, ],如图 4.7 所示,上行为欲扩展的 shape,下面为现有 shape:
首先将 2 个 shape 靠右对齐,对于通道维度 c,张量的现长度为 1,则默认此数据同样适合当前维度的其他位置,将数据逻辑上复制 − 1份,长度变为 c;对于不存在的 b 和 h 维度,则自动插入新维度,新维度长度为 1,同时默认当前的数据普适于新维度的其他位置,即对于其它的图片、其他的行来说,与当前的这一行的数据完全一致。这样将数据b,h 维度的长度自动扩展为 b,h,如图 4.8 所示:
通过 tf.broadcast_to(x, new_shape)
可以显式将现有 shape 扩张为 new_shape:
A = tf.random.normal([32,1])
tf.broadcast_to(A, [2,32,32,3])
<tf.Tensor: id=13, shape=(2, 32, 32, 3), dtype=float32, numpy=
array([[[[-1.7571245 , -1.7571245 , -1.7571245 ],
[ 1.580159 , 1.580159 , 1.580159 ],
[-1.5324328 , -1.5324328 , -1.5324328 ],...
可以看到,在普适性原则的指导下,Broadcasting 机制变得直观好理解,它的设计是非常符合人的思维模式。
在 c 维度上,张量已经有 2 个特征数据,新 shape 对应维度长度为 c( ≠ 2,比如 c=3),那么当前维度上的这 2 个特征无法普适到其他长度,故不满足普适性原则,无法应用Broadcasting 机制,将会触发错误:
A = tf.random.normal([32,2])
tf.broadcast_to(A, [2,32,32,4])
InvalidArgumentError: Incompatible shapes: [32,2] vs. [2,32,32,4]
[Op:BroadcastTo]
在进行张量运算时,有些运算可以在处理不同 shape 的张量时,会隐式自动调用Broadcasting 机制,如+,-,*,/等运算等,将参与运算的张量 Broadcasting 成一个公共shape,再进行相应的计算,如图 4.10 所示,演示了 3 种不同 shape 下的张量 A,B 相加的例子
简单测试一下基本运算符的自动 Broadcasting 机制:
a = tf.random.normal([2,32,32,1])
b = tf.random.normal([32,32])
a+b,a-b,a*b,a/b
这些运算都能 Broadcasting 成[2,32,32,32]的公共 shape,再进行运算。熟练掌握并运用Broadcasting 机制可以让代码更简洁,计算效率更高。
前面的章节我们已经使用了基本的加减乘除等数学运算函数,本节我们将系统地介绍TensorFlow 中常见的数学运算函数。
加减乘除是最基本的数学运算,分别通过 tf.add
, tf.subtract
, tf.multiply
, tf.divide
函数实现,TensorFlow 已经重载了+ −∗/运算符,一般推荐直接使用运算符来完成加减乘除运算。
整除和余除也是常见的运算之一,分别通过//和%运算符实现。我们来演示整除运算:
a = tf.range(5)
b = tf.constant(2)
a//b
<tf.Tensor: id=115, shape=(5,), dtype=int32, numpy=array([0, 0, 1, 1, 2])>
余除运算:
a%b
<tf.Tensor: id=117, shape=(5,), dtype=int32, numpy=array([0, 1, 0, 1, 0])>
通过 tf.pow(x, a)
可以方便地完成 = x a x^{a} xa乘方运算,也可以通过运算符**实现 ∗∗ 运算,实现如下:
x = tf.range(4)
tf.pow(x,3)
<tf.Tensor: id=124, shape=(4,), dtype=int32, numpy=array([ 0, 1, 8, 27])>
x**2
<tf.Tensor: id=127, shape=(4,), dtype=int32, numpy=array([0, 1, 4, 9])>
设置指数为 1 a \frac{1}{a} a1形式即可实现根号运算: x a \sqrt[a]{x} ax:
x=tf.constant([1.,4.,9.])
x**(0.5)
<tf.Tensor: id=139, shape=(3,), dtype=float32, numpy=array([1., 2., 3.],
dtype=float32)>
特别地,对于常见的平方和平方根运算,可以使用 tf.square(x)
和 tf.sqrt(x)
实现。平方运算实现如下:
x = tf.range(5)
x = tf.cast(x, dtype=tf.float32)
x = tf.square(x)
<tf.Tensor: id=159, shape=(5,), dtype=float32, numpy=array([ 0., 1., 4.,
9., 16.], dtype=float32)>
平方根运算实现如下:
tf.sqrt(x)
<tf.Tensor: id=161, shape=(5,), dtype=float32, numpy=array([0., 1., 2., 3.,
4.], dtype=float32)>
通过 tf.pow(a, x)
或者**运算符可以方便实现指数运算 a x a^{x} ax:
x = tf.constant([1.,2.,3.])
2**x
<tf.Tensor: id=179, shape=(3,), dtype=float32, numpy=array([2., 4., 8.],
dtype=float32)>
特别地,对于自然指数 e x e^{x} ex,可以通过 tf.exp(x)
实现:
tf.exp(1.)
<tf.Tensor: id=182, shape=(), dtype=float32, numpy=2.7182817>
在 TensorFlow 中,自然对数 log e x \log _{e} x logex可以通过 tf.math.log(x)
实现:
x=tf.exp(3.)
tf.math.log(x)
<tf.Tensor: id=186, shape=(), dtype=float32, numpy=3.0>
如果希望计算其他底数的对数,可以根据对数的换底公式:
log a x = log e x log e a \log _{a} x=\frac{\log _{e} x}{\log _{e} a} logax=logealogex
间接的通过 tf.math.log(x)
实现。如计算 log 10 x \log _{10} x log10x可以通过 log e x log e 10 \frac{\log _{e} x}{\log _{e} 10} loge10logex实现如下:
x = tf.constant([1.,2.])
x = 10**x
tf.math.log(x)/tf.math.log(10.)
<tf.Tensor: id=222, shape=(2,), dtype=float32, numpy=array([0. ,
2.3025851], dtype=float32)>
实现起来相对繁琐,也许 TensorFlow 以后会推出任意底数的 log 函数.
神经网络中间包含了大量的矩阵相乘运算,前面我们已经介绍了通过@运算符可以方便的实现矩阵相乘,还可以通过 tf.matmul(a, b)
实现。需要注意的是,TensorFlow 中的矩阵相乘可以使用批量方式,也就是张量 a,b 的维度数可以大于 2。当张量 a,b 维度数大于 2时,TensorFlow 会选择 a,b 的最后两个维度进行矩阵相乘,前面所有的维度都视作 Batch 维度。
根据矩阵相乘的定义,a 和 b 能够矩阵相乘的条件是,a 的倒数第一个维度长度(列)和b 的倒数第二个维度长度(行)必须相等。比如张量 a shape:[4,3,28,32]可以与张量 bshape:[4,3,32,2]进行矩阵相乘:
a = tf.random.normal([4,3,23,32])
b = tf.random.normal([4,3,32,2])
a@b
<tf.Tensor: id=236, shape=(4, 3, 28, 2), dtype=float32, numpy=
array([[[[-1.66706240e+00, -8.32602978e+00],
[ 9.83304405e+00, 8.15909767e+00],
[ 6.31014729e+00, 9.26124632e-01],…
得到 shape 为[4,3,28,2]的结果。
矩阵相乘函数支持自动 Broadcasting
机制:
a = tf.random.normal([4,28,32])
b = tf.random.normal([32,16])
tf.matmul(a,b)
<tf.Tensor: id=264, shape=(4, 28, 16), dtype=float32, numpy=
array([[[-1.11323869e+00, -9.48194981e+00, 6.48123884e+00, ...,
6.53280640e+00, -3.10894990e+00, 1.53050375e+00],
[ 4.35898495e+00, -1.03704405e+01, 8.90656471e+00, ...,
到现在为止,我们已经介绍了如何创建张量,对张量进行索引切片,维度变换和常见的数学运算等操作。本节我们将利用我们已经学到的知识去完成三层神经网络的实现:
out = relu{relu{relu [ X @ W 1 + b 1 ] @ W 2 + b 2 } @ W 3 + b 3 } \left.\text {out}\left.=\text {relu\{relu\{relu}\left[X @ W_{1}+b_{1}\right] @ W_{2}+b_{2}\right\} @ W_{3}+b_{3}\right\} out=relu{relu{relu[X@W1+b1]@W2+b2}@W3+b3}
我们采用的数据集是 MNIST 手写数字图片集,输入节点数为 784,第一层的输出节点数是256,第二层的输出节点数是 128,第三层的输出节点是 10,也就是当前样本属于 10 类别的概率。
首先加载所需要的库
import matplotlib.pyplot as plt
import tensorflow as tf
import tensorflow.keras.datasets as datasets
plt.rcParams['font.size'] = 16
plt.rcParams['font.family'] = ['STKaiti']
plt.rcParams['axes.unicode_minus'] = False
其次加载数据集
# 加载 MNIST 数据集
(x, y), (x_val, y_val) = datasets.mnist.load_data()
# 转换为浮点张量, 并缩放到-1~1
x = tf.convert_to_tensor(x, dtype=tf.float32) / 255.
# 转换为整形张量
y = tf.convert_to_tensor(y, dtype=tf.int32)
# one-hot 编码
y = tf.one_hot(y, depth=10)
# 改变视图, [b, 28, 28] => [b, 28*28]
x = tf.reshape(x, (-1, 28 * 28))
# 构建数据集对象
train_dataset = tf.data.Dataset.from_tensor_slices((x, y))
# 批量训练
train_dataset = train_dataset.batch(200)
接着创建每个非线性函数的 w,b 参数张量:
# 每层的张量都需要被优化,故使用 Variable 类型,并使用截断的正太分布初始化权值张量
# 偏置向量初始化为 0 即可
# 第一层的参数
w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
# 第二层的参数
w2 = tf.Variable(tf.random.truncated_normal([256, 128], stddev=0.1))
b2 = tf.Variable(tf.zeros([128]))
# 第三层的参数
w3 = tf.Variable(tf.random.truncated_normal([128, 10], stddev=0.1))
b3 = tf.Variable(tf.zeros([10]))
在前向计算时,首先将 shape 为[, 28,28]的输入数据 Reshape 为[, 784]:
# [b, 28, 28] => [b, 28*28]
(x,y),(x_val,y_val)=datasets.mnist.load_data()#60000训练集/10000测试集
x=2*tf.convert_to_tensor(x,dtype=tf.float32)/255-1 #转换为张量,缩放到-1~1 60000*28*28
x = tf.reshape(x, [-1, 28*28])
完成第一个非线性函数的计算,我们这里显示地进行 Broadcasting:
# [b, 784]@[784, 256] + [256] => [b, 256] + [256] => [b, 256] +[b, 256]
h1 = x@w1 + tf.broadcast_to(b1, [x.shape[0], 256])
h1 = tf.nn.relu(h1)
同样的方法完成第二个和第三个非线性函数的前向计算,输出层可以不使用 ReLU 激活函数:
# [b, 256] => [b, 128]
h2 = h1@w2 + b2
h2 = tf.nn.relu(h2)
# [b, 128] => [b, 10]
out = h2@w3 + b3
将真实的标注张量 y 转变为 one-hot 编码,并计算与 out 的均方差:
# mse = mean(sum(y-out)^2)
# [b, 10]
loss = tf.square(y_onehot - out)
# mean: scalar
loss = tf.reduce_mean(loss)
上述的前向计算过程都需要包裹在 with tf.GradientTape() as tape
上下文中,使得前向计算时能够保存计算图信息,方便反向求导运算。
通过 tape.gradient()
函数求得网络参数到梯度信息:
# compute gradients
lr=0.01
epochs=20
losses = []
for epoch in range(epochs):
for step, (x, y) in enumerate(train_dataset):
with tf.GradientTape() as tape:
# 第一层计算, [b, 784]@[784, 256] + [256] => [b, 256] + [256] => [b,256] + [b, 256]
h1 = x @ w1 + tf.broadcast_to(b1, (x.shape[0], 256))
h1 = tf.nn.relu(h1) # 通过激活函数
# 第二层计算, [b, 256] => [b, 128]
h2 = h1 @ w2 + b2
h2 = tf.nn.relu(h2)
# 输出层计算, [b, 128] => [b, 10]
out = h2 @ w3 + b3
# 计算网络输出与标签之间的均方差, mse = mean(sum(y-out)^2)
# [b, 10]
loss = tf.square(y - out)
# 误差标量, mean: scalar
loss = tf.reduce_mean(loss)
# 自动梯度,需要求梯度的张量有[w1, b1, w2, b2, w3, b3]
grads = tape.gradient(loss, [w1, b1, w2, b2, w3, b3])
# 梯度更新, assign_sub 将当前值减去参数值,原地更新
w1.assign_sub(lr * grads[0])
b1.assign_sub(lr * grads[1])
w2.assign_sub(lr * grads[2])
b2.assign_sub(lr * grads[3])
w3.assign_sub(lr * grads[4])
b3.assign_sub(lr * grads[5])
if step % 100 == 0:
print(epoch, step, 'loss:', loss.numpy())
print(loss.numpy())
losses.append(loss)
并按照
θ ′ = θ − η ∗ ∂ L ∂ θ \theta^{\prime}=\theta-\eta * \frac{\partial \mathcal{L}}{\partial \theta} θ′=θ−η∗∂θ∂L
来更新网络参数:
# w1 = w1 - lr * w1_grad
w1.assign_sub(lr * grads[0])
b1.assign_sub(lr * grads[1])
w2.assign_sub(lr * grads[2])
b2.assign_sub(lr * grads[3])
w3.assign_sub(lr * grads[4])
b3.assign_sub(lr * grads[5])
其中 assign_sub()
将原地(In-place)减去给定的参数值,实现参数的自我更新操作。网络训练
误差值的变化曲线如图 4.11 所示。
x = [i for i in range(0, epochs)]
# 绘制曲线
plt.plot(x, losses, color='blue', marker='s', label='训练')
plt.xlabel('Epoch')
plt.ylabel('MSE')
plt.legend()
plt.savefig('MNIST数据集的前向传播训练误差曲线.png')
#plt.close()
plt.show()
#!/usr/bin/env python
# encoding: utf-8
import matplotlib.pyplot as plt
import tensorflow as tf
import tensorflow.keras.datasets as datasets
plt.rcParams['font.size'] = 16
plt.rcParams['font.family'] = ['STKaiti']
plt.rcParams['axes.unicode_minus'] = False
def load_data():
# 加载 MNIST 数据集
(x, y), (x_val, y_val) = datasets.mnist.load_data()
# 转换为浮点张量, 并缩放到-1~1
x = tf.convert_to_tensor(x, dtype=tf.float32) / 255.
# 转换为整形张量
y = tf.convert_to_tensor(y, dtype=tf.int32)
# one-hot 编码
y = tf.one_hot(y, depth=10)
# 改变视图, [b, 28, 28] => [b, 28*28]
x = tf.reshape(x, (-1, 28 * 28))
# 构建数据集对象
train_dataset = tf.data.Dataset.from_tensor_slices((x, y))
# 批量训练
train_dataset = train_dataset.batch(200)
return train_dataset
def init_paramaters():
# 每层的张量都需要被优化,故使用 Variable 类型,并使用截断的正太分布初始化权值张量
# 偏置向量初始化为 0 即可
# 第一层的参数
w1 = tf.Variable(tf.random.truncated_normal([784, 256], stddev=0.1))
b1 = tf.Variable(tf.zeros([256]))
# 第二层的参数
w2 = tf.Variable(tf.random.truncated_normal([256, 128], stddev=0.1))
b2 = tf.Variable(tf.zeros([128]))
# 第三层的参数
w3 = tf.Variable(tf.random.truncated_normal([128, 10], stddev=0.1))
b3 = tf.Variable(tf.zeros([10]))
return w1, b1, w2, b2, w3, b3
def train_epoch(epoch, train_dataset, w1, b1, w2, b2, w3, b3, lr=0.001):
for step, (x, y) in enumerate(train_dataset):
with tf.GradientTape() as tape:
# 第一层计算, [b, 784]@[784, 256] + [256] => [b, 256] + [256] => [b,256] + [b, 256]
h1 = x @ w1 + tf.broadcast_to(b1, (x.shape[0], 256))
h1 = tf.nn.relu(h1) # 通过激活函数
# 第二层计算, [b, 256] => [b, 128]
h2 = h1 @ w2 + b2
h2 = tf.nn.relu(h2)
# 输出层计算, [b, 128] => [b, 10]
out = h2 @ w3 + b3
# 计算网络输出与标签之间的均方差, mse = mean(sum(y-out)^2)
# [b, 10]
loss = tf.square(y - out)
# 误差标量, mean: scalar
loss = tf.reduce_mean(loss)
# 自动梯度,需要求梯度的张量有[w1, b1, w2, b2, w3, b3]
grads = tape.gradient(loss, [w1, b1, w2, b2, w3, b3])
# 梯度更新, assign_sub 将当前值减去参数值,原地更新
w1.assign_sub(lr * grads[0])
b1.assign_sub(lr * grads[1])
w2.assign_sub(lr * grads[2])
b2.assign_sub(lr * grads[3])
w3.assign_sub(lr * grads[4])
b3.assign_sub(lr * grads[5])
if step % 100 == 0:
print(epoch, step, 'loss:', loss.numpy())
return loss.numpy()
def train(epochs):
losses = []
train_dataset = load_data()
w1, b1, w2, b2, w3, b3 = init_paramaters()
for epoch in range(epochs):
loss = train_epoch(epoch, train_dataset, w1, b1, w2, b2, w3, b3, lr=0.001)
losses.append(loss)
x = [i for i in range(0, epochs)]
# 绘制曲线
plt.plot(x, losses, color='blue', marker='s', label='训练')
plt.xlabel('Epoch')
plt.ylabel('MSE')
plt.legend()
plt.savefig('MNIST数据集的前向传播训练误差曲线.png')
plt.close()
if __name__ == '__main__':
train(epochs=20)