数据批量的概念
通常来说,深度学习中所有数据张量的第一个轴(也就是轴0,因为索引从0开始)都是样本轴[samples axis,有时也叫样本维度(samples dimension)]。深度学习模型不会一次性处理整个数据集,而是将数据拆分成小批量。下面是MNIST数据集的一个批量,批量大小为128。
batch = train_images[:128]
对于这种批量张量,第一个轴(轴0)叫作批量轴(batch axis)或批量维度(batch dimension)。在使用Keras和其他深度学习库时,你会经常遇到“批量轴”这个术语。
现实世界中的数据张量实例
向量数据:形状为(samples, features)的2阶张量,每个样本都是一个数值(“特征”)向量。时间序列数据或序列数据:形状为(samples, timesteps, features)的3阶张量,每个样本都是特征向量组成的序列(序列长度为timesteps)。图像数据:形状为(samples, height, width, channels)的4阶张量,每个样本都是一个二维像素网格,每个像素则由一个“通道”(channel)向量表示。视频数据:形状为(samples, frames, height, width, channels)的5阶张量,每个样本都是由图像组成的序列(序列长度为frames)。
向量数据
这是最常见的一类数据。对于这种数据集,每个数据点都被编码为一个向量,因此一个数据批量就被编码为一个2阶张量(由向量组成的数组),其中第1个轴是样本轴,第2个轴是特征轴(features axis)。
时间序列数据或序列数据
当时间(或序列顺序)对数据很重要时,应该将数据存储在带有时间轴的3阶张量中。每个样本可被编码为一个向量序列(2阶张量),因此一个数据批量就被编码为一个3阶张量。
图像数据
图像通常具有3个维度:高度、宽度和颜色深度。虽然灰度图像只有一个颜色通道,因此可以保存在2阶张量中,但按照惯例,图像张量都是3阶张量。
图像张量的形状有两种约定:通道在后(channels-last)的约定(这是TensorFlow的标准)和通道在前(channels-first)的约定(使用这种约定的人越来越少)。
视频数据
视频数据是现实世界中为数不多的需要用到5阶张量的数据类型。视频可以看作帧的序列,每一帧都是一张彩色图像。由于每一帧都可以保存在一个形状为(height, width,color_depth)的3阶张量中,因此一个视频(帧的序列)可以保存在一个形状为(frames, height, width, color_depth)的4阶张量中,由多个视频组成的批量则可以保存在一个形状为(samples, frames, height, width, color_depth)的5阶张量中。
神经网络的“齿轮”:张量运算
所有计算机程序最终都可以简化为对二进制输入的一些二进制运算(AND、OR、NOR等),与此类似,深度神经网络学到的所有变换也都可以简化为对数值数据张量的一些张量运算(tensor operation)或张量函数(tensor function),如张量加法、张量乘法等。下面是一个Keras层的实例。
keras.layers.Dense(512, activation="relu")
这个层理解为一个函数,其输入是一个矩阵,返回的是另一个矩阵,即输入张量的新表示。这个函数具体如下(其中W是一个矩阵,b是一个向量,二者都是该层的属性)。
output = relu(dot(input, W) + b)
这里有3个张量运算。输入张量和张量W之间的点积运算(dot)。由此得到的矩阵与向量b之间的加法运算(+)。relu运算。relu(x)就是max(x, 0),relu代表“修正线性单元”(rectified linear unit)。
逐元素运算
relu运算和加法都是逐元素(element-wise)运算,即该运算分别应用于张量的每个元素。也就是说,这些运算非常适合大规模并行实现(向量化实现)。如果你想对逐元素运算编写一个简单的Python实现,那么可以使用for循环。下列代码是对逐元素relu运算的简单实现。
def naive_relu(x):
#x是一个2阶NumPy张量
assert len(x.shape) == 2
#避免覆盖输入张量
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] = max(x[i, j], 0)
return x
对于加法,可采用同样的实现方法。
def naive_add(x, y):
#x和y是2阶NumPy张量
assert len(x.shape) == 2
assert x.shape == y.shape
#避免覆盖输入张量
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[i, j]
return x
利用同样的方法,可以实现逐元素的乘法、减法等。在实践中处理NumPy数组时,这些运算都是优化好的NumPy内置函数。这些函数将大量运算交给基础线性代数程序集(Basic Linear Algebra Subprograms,BLAS)实现。BLAS是低层次(low-level)、高度并行、高效的张量操作程序,通常用Fortran或C语言来实现。因此,在NumPy中可以直接进行下列逐元素运算,速度非常快。
import numpy as np
#逐元素加法
#z = x + y
# 逐元素relu
#z = np.maximum(z, 0.)
我们来看一下两种方法运行时间的差别。
import time
x = np.random.random((20, 100))
y = np.random.random((20, 100))
t0 = time.time()
for _ in range(1000):
z = x + y
z = np.maximum(z, 0.)
print("Took: {0:.2f} s".format(time.time() - t0))
只需要0.00秒。与之相对,前面手动编写的简单实现耗时长达0.77秒。
t0 = time.time()
for _ in range(1000):
z = naive_add(x, y)
z = naive_relu(z)
print("Took: {0:.2f} s".format(time.time() - t0))
同样,在GPU上运行TensorFlow代码,逐元素运算都是通过完全向量化的CUDA来完成的,可以最大限度地利用高度并行的GPU芯片架构。
广播
naive_add的简单实现仅支持两个形状相同的2阶张量相加,但在Dense层中,我们将一个2阶张量与一个向量相加。如果将两个形状不同的张量相加,会发生什么?在没有歧义且可行的情况下,较小的张量会被广播(broadcast),以匹配较大张量的形状。广播包含以下两步。(1)向较小张量添加轴[叫作广播轴(broadcast axis)],使其ndim与较大张量相同。(2)将较小张量沿着新轴重复,使其形状与较大张量相同。我们来看一个具体的例子。假设X的形状是(32, 10),y的形状是(10,)。
import numpy as np
#X是一个形状为(32, 10)的随机矩阵
X = np.random.random((32, 10))
#y是一个形状为(10,)的随机向量
y = np.random.random((10,))
首先,我们向y添加第1个轴(空的),这样y的形状变为(1, 10)。
#现在y的形状变为(1, 10)
y = np.expand_dims(y, axis=0)
然后,我们将y沿着这个新轴重复32次,这样得到的张量Y的形状为(32, 10),并且Y[i,:] == y for i in range(0, 32)
#将y沿着轴0重复32次后得到Y,其形状为(32, 10)
Y = np.concatenate([y] * 32, axis=0)
现在,我们可以将X和Y相加,因为它们的形状相同。在实际的实现过程中并不会创建新的2阶张量,因为那样做非常低效。重复操作完全是虚拟的,它只出现在算法中,而没有出现在内存中。但想象将向量沿着新轴重复10次,是一种很有用的思维模型。下面是一种简单实现。
def naive_add_matrix_and_vector(x, y):
#x是一个2阶NumPy张量
assert len(x.shape) == 2
# y是一个NumPy向量
assert len(y.shape) == 1
assert x.shape[1] == y.shape[0]
#避免覆盖输入张量
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[j]
return x
如果一个张量的形状是(a, b, …, n, n+1, …, m),另一个张量的形状是(n, n+1, …, m),那么通常可以利用广播对这两个张量做逐元素运算。广播会自动应用于从a到n-1的轴。下面这个例子利用广播对两个形状不同的张量做逐元素maximum运算。
import numpy as np
#x是一个形状为(64, 3, 32, 10)的随机张量
x = np.random.random((64, 3, 32, 10))
#y是一个形状为(32, 10)的随机张量
y = np.random.random((32, 10))
#输出z的形状为(64, 3, 32, 10),与x相同
z = np.maximum(x, y)
本文代码汇总:
def naive_add(x, y):
#x和y是2阶NumPy张量
assert len(x.shape) == 2
assert x.shape == y.shape
#避免覆盖输入张量
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[i, j]
return x
import numpy as np
#逐元素加法
#z = x + y
# 逐元素relu
#z = np.maximum(z, 0.)
import time
x = np.random.random((20, 100))
y = np.random.random((20, 100))
t0 = time.time()
for _ in range(1000):
z = x + y
z = np.maximum(z, 0.)
print("Took: {0:.2f} s".format(time.time() - t0))
t0 = time.time()
for _ in range(1000):
z = naive_add(x, y)
z = naive_relu(z)
print("Took: {0:.2f} s".format(time.time() - t0))
import numpy as np
#X是一个形状为(32, 10)的随机矩阵
X = np.random.random((32, 10))
#y是一个形状为(10,)的随机向量
y = np.random.random((10,))
#现在y的形状变为(1, 10)
y = np.expand_dims(y, axis=0)
#将y沿着轴0重复32次后得到Y,其形状为(32, 10)
Y = np.concatenate([y] * 32, axis=0)
def naive_add_matrix_and_vector(x, y):
#x是一个2阶NumPy张量
assert len(x.shape) == 2
# y是一个NumPy向量
assert len(y.shape) == 1
assert x.shape[1] == y.shape[0]
#避免覆盖输入张量
x = x.copy()
for i in range(x.shape[0]):
for j in range(x.shape[1]):
x[i, j] += y[j]
return x
import numpy as np
#x是一个形状为(64, 3, 32, 10)的随机张量
x = np.random.random((64, 3, 32, 10))
#y是一个形状为(32, 10)的随机张量
y = np.random.random((32, 10))
#输出z的形状为(64, 3, 32, 10),与x相同
z = np.maximum(x, y)