PyTorch 深度学习——使用张量表示真实世界数据

本章涵盖以下内容

  • 将真实世界数据表示为 PyTorch 张量
  • 处理多种数据类型
  • 从文件中加载数据
  • 将数据转换为张量
  • 调整张量形状,使其能够作为神经网络模型的输入

在上一章中,我们已经学到,张量是 PyTorch 中数据的基本构件。对于 PyTorch 来说,神经网络接收张量作为输入,并产生张量作为输出。事实上,神经网络内部的所有运算,以及优化过程中的所有运算,本质上都是张量与张量之间的运算;而神经网络中的所有参数(例如权重和偏置)也都是张量。对如何操作张量、如何高效地对它们进行索引有良好的感觉,是成功使用 PyTorch 这类工具的核心。现在你已经掌握了张量的基础知识,随着你继续读完这本书,你对张量的熟练度也会不断提高。

这里有一个我们现在已经可以回答的问题:怎样把一段数据、一段视频,或者一行文本,用一种适合训练深度学习模型的方式表示成张量?本章我们就来学习这一点。我们会覆盖不同类型的数据,重点放在与本书相关的那些数据类型上,并展示如何把这些数据表示为张量。接着,我们会学习如何从最常见的磁盘文件格式中加载这些数据,并对这些数据类型的结构形成直观认识,从而理解该如何把它们整理好,以便用于训练神经网络。很多时候,我们的原始数据并不会天然就以最适合目标问题的形式存在,因此我们也会借机继续练习张量操作技巧,接触一些更有意思的张量运算。

本章的每一节都会介绍一种数据类型,并配套一个相应的数据集。虽然我们在编排上让每一种数据类型都建立在前一种之上,但如果你愿意,也完全可以跳着读。

在本书剩余部分中,我们会频繁使用图像数据和体数据(volumetric data),因为它们都是非常常见的数据类型,而且也很适合在书本形式中展示。除此之外,我们还会讲到表格数据、时间序列和文本,因为这些内容对不少读者来说也会很有价值。

俗话说,一图胜千言,所以我们先从图像数据开始。然后,我们会借助一种使用医学数据的三维数组,来演示如何处理表示病人体内解剖结构体积信息的数据。接下来,我们会处理葡萄酒相关的表格数据,就像电子表格里会看到的那种。之后,我们会转向"有序的表格数据",也就是来自共享单车项目的时间序列数据。最后,我们会浅浅涉足一下 Jane Austen 的文本数据。文本数据保留了"有序"这一特性,但同时又引入了一个新问题:如何把单词表示成数字数组。

在每一节中,我们都会停在深度学习研究者真正开始工作的地方:也就是把数据送进模型之前。我们鼓励你保留这些数据集;等到下一章我们开始学习如何训练神经网络模型时,它们都会成为极好的练习材料。

4.1 处理图像

卷积神经网络的出现彻底改变了计算机视觉,而基于图像的系统自此也获得了一整套全新的能力。那些过去需要复杂流水线、并依赖高度精调算法模块组合来解决的问题,如今只需利用成对的"输入---期望输出"样本训练端到端网络,就能以空前的性能水平得到解决。要真正利用这些先进计算机视觉能力,我们首先需要能够从常见图像格式中加载图像,然后再把图像数据转换成一种张量表示,使图像的各个部分按照 PyTorch 所期望的方式组织起来。

图像本质上只是一个由数字构成的网格,这些数字按行和列排列,对应一个个像素。每个像素里要么只包含一个标量值(形成灰度图),要么包含多个标量值(通常表示不同颜色,或者像某些专用相机提供的深度信息之类的其他特征)。

表示单个像素值的标量,通常会像消费级相机那样,用 8 位整数来编码。而在医学、科学和工业场景中,出现更高数值精度也并不罕见,例如 12 位或 16 位。这种更高精度可以提供更宽的取值范围,或者更高的灵敏度,特别是在像素所编码的是某种物理属性时,例如骨密度、温度或者深度信息。

4.1.1 添加颜色通道

前面我们提到过颜色。将颜色编码为数字的方法有很多种(说"很多种"其实都有点轻描淡写了)。其中最常见的是 RGB,也就是用三个数值分别表示红、绿、蓝三种颜色的强度。我们可以把一个颜色通道理解为:仅针对某一种颜色所形成的灰度强度图,就像你戴上一副纯红色墨镜去看某个场景时所看到的效果一样。图 4.1 展示了一道彩虹,其中每个 RGB 通道都捕捉了光谱中的某一部分(图中做了简化,例如省略了橙色和黄色带------它们通常由红色和绿色组合表示)。

图 4.1 一道彩虹被拆分成红、绿、蓝三个通道。此处为灰度印刷版;完整彩色图请见本书在线版本。

彩虹中的红色带,在图像的红色通道中最亮;而蓝色通道中,高强度区域既包括彩虹的蓝色带,也包括天空本身。另外还要注意,白色云朵在三个通道中都是高强度的。

4.1.2 加载图像文件

图像有多种不同的文件格式,不过幸运的是,在 Python 中加载图像的方法非常多。我们先从使用 imageio 模块加载一张 JPG 图像开始(code/p1ch4/1_image_dog.ipynb)。

代码清单 4.1 加载一张图像

ini 复制代码
# In[2]:
import imageio.v2 as imageio
img_arr = imageio.imread('../data/p1ch4/image-dog/bobby.jpg')
img_arr.shape

# Out[2]:
(720, 1280, 3)

注意 本章中我们会一直使用 imageio,因为它能够用统一的 API 处理不同类型的数据。对于很多用途来说,使用 TorchVision 作为处理图像与视频数据的默认选择其实非常合适。这里只是为了更轻量地做一些探索,所以我们选用了 imageio

此时,img_arr 是一个类似 NumPy 数组的对象,具有三个维度:两个空间维度------宽和高------以及第三个对应红、绿、蓝通道的维度。任何能输出 NumPy 数组的库,都足以让我们得到一个 PyTorch 张量。唯一需要注意的是维度的排列顺序。PyTorch 中处理图像数据的模块要求张量按照 C × H × W 的布局组织,也就是依次为:通道、高度、宽度。

4.1.3 改变布局

我们可以使用张量的 permute 方法,并传入"新维度对应旧维度"的顺序,来把张量变换到合适的布局。对于前面得到的、形状为 H × W × C 的输入张量,只要把原本的第 2 维放到最前面,再依次接上原本的第 0 维和第 1 维,就能得到正确布局:

ini 复制代码
# In[3]:
img = torch.from_numpy(img_arr)
out = img.permute(2, 0, 1)
out.shape

# Out[3]:
torch.Size([3, 720, 1280])

这一点我们前面其实已经见过,但还是要注意:这个操作不会复制张量数据 。相反,outimg 共享同一块底层 storage,它只是在张量层面调整了 size 和 stride 信息。这很好,因为这个操作非常便宜------不过也顺便提醒一句,修改 img 中某个像素时,也会导致 out 发生变化。

其他深度学习框架使用的布局可能不同。例如,TensorFlow 最初就是把通道维放在最后,因此采用 H × W × C 布局(现在它已经支持多种布局)。从底层性能角度看,这种策略各有利弊;但对我们现在的关注点而言,只要把张量 reshape 成正确形式,就没有本质区别。

到目前为止,我们描述的还只是单张图像。接下来,我们继续沿用前面对其他数据类型所采用的策略:为了构建一个可作为神经网络输入的多图像数据集,我们会把多张图像沿着第一个维度堆叠成一个 batch,从而得到一个 N × C × H × W 的张量。

我们可以预先分配一个大小合适的张量,然后把某个目录中的图像加载进来并填充到其中,像这样:

ini 复制代码
# In[4]:
import os
data_dir = '../data/p1ch4/image-cats/'
png_files = [f for f in os.listdir(data_dir) if f.endswith('.png')]
batch = torch.zeros(len(png_files), 3, 256, 256, dtype=torch.uint8)

这段代码表明:我们的 batch 将由 len(png_files) 张 RGB 图像组成,每张图像高度为 256 像素、宽度为 256 像素。注意这里张量的数据类型:我们预期每种颜色都由一个 8 位整数来表示,这和大多数标准消费级相机输出的照片格式是一致的。接下来,我们就可以把输入目录中的所有 PNG 图像读出来并存入这个张量中(1_image_dog.ipynb1_image_dog.ipynb)。

代码清单 4.2 读取所有图像文件

ini 复制代码
# In[5]:
for i, filename in enumerate(png_files):
    img_arr = imageio.imread(os.path.join(data_dir, filename))
    img_t = torch.from_numpy(img_arr)
    img_t = img_t.permute(2, 0, 1)
    img_t = img_t[:3]           #1
    batch[i] = img_t
#1 这里我们只保留前三个通道。有些图像还会带有一个表示透明度的 alpha 通道,但我们的网络只接受 RGB 输入。

4.1.4 数据归一化

前面我们已经提到过,神经网络通常希望输入是浮点张量。一般来说,当输入数据大致位于 0 到 1,或者 --1 到 1 之间时,神经网络的训练表现最好(这与其内部构件的定义方式有关)。

因此,一个很常见的操作就是:先把张量转换成浮点数类型,再对像素值进行归一化。类型转换很简单,但归一化稍微复杂一些,因为这取决于我们想把输入的哪一个数值范围映射到 0 到 1(或者 --1 到 1)之间。一个简单办法是用 255 去除像素值(255 是 8 位无符号整数所能表示的最大值):

ini 复制代码
# In[6]:
batch = batch.float()
batch /= 255.0

另一种办法则是计算输入数据的均值和标准差,并把它缩放到每个通道都具有零均值和单位标准差的形式------这种技术通常称为标准化(standardization)

ini 复制代码
# In[7]:
n_channels = batch.shape[1]
for c in range(n_channels):
    mean = torch.mean(batch[:, c])
    std = torch.std(batch[:, c])
    batch[:, c] = (batch[:, c] - mean) / std

注意 这里我们只对一个 batch 的图像做归一化,因为目前我们还不知道如何对整个数据集执行这类操作。在处理图像时,一个良好实践是:提前在全部训练数据上计算出均值和标准差,然后在训练过程中始终使用这些固定的、预先算好的数值来做减法和除法。

我们还可以对输入做很多其他操作,例如几何变换:旋转、缩放、裁剪等。这些操作有时有助于训练,有时则是为了让任意输入满足某个网络对输入的要求,比如图像尺寸。到第 12.6 节时,我们会接触到很多这类策略。现在你只需要记住:图像处理方面,其实有很多可用手段。

4.2 三维图像:体数据

我们已经学会了如何加载并表示二维图像,也就是像普通相机拍出来的那种图像。在某些场景中,比如涉及 CT(计算机断层扫描)图像的医学影像应用,我们通常处理的是一系列沿着人体头到脚方向堆叠起来的图像,每一张都对应人体某个横截面的切片。在 CT 图像中,像素强度代表的是身体不同部分的密度------从低到高依次大致是肺、脂肪、水、肌肉、骨骼------在临床工作站显示时,通常映射为从暗到亮的视觉效果。每个点处的密度,是通过测量 X 射线穿过人体后到达探测器的强度,再经过一些复杂数学过程,将原始传感器数据反卷积为完整体积数据而计算出来的。

CT 通常只有一个强度通道,类似灰度图像。这意味着,在原生数据格式中,通道维往往会被省略;所以,和上一节一样,原始数据通常具有三个维度。通过把一张张二维切片沿某个方向堆叠成一个三维张量,我们就可以构建出表示某个受检者三维解剖结构的体数据。与图 4.1 中的额外维度不同,图 4.2 里的这个额外维度表示的是物理空间上的位置偏移,而不是可见光谱中的某个波段。

图 4.2 一组 CT 扫描切片,从头顶一直到下颌线

本书第二部分将专门处理一个真实世界中的医学影像问题,因此这里我们不深入展开医学影像数据格式的细节。现在只要知道一点就够了:在本质上,存储体数据的张量与存储图像数据的张量并没有什么根本区别。只不过,我们在通道维之后多了一个深度维,因此最终得到的是一个形状为 N × C × D × H × W 的五维张量。

4.2.1 加载一种专用格式

下面我们用 imageio 模块中的 volread 函数加载一个示例 CT 扫描。这个函数接收一个目录作为参数,然后会把其中同一序列的所有 DICOM(Digital Imaging and Communications in Medicine)文件(来自 Cancer Imaging Archive 的 CPTAC-LSCC 集合:mng.bz/AGZg)组装成一个 NumPy 三维数组(code/p1ch4/2_volumetric_ct.ipynb)。

代码清单 4.3 加载 DICOM 序列并检查形状

ini 复制代码
# In[2]:
import imageio

dir_path = "../data/p1ch4/volumetric-dicom/2-LUNG 3.0  B70f-04083"
vol_arr = imageio.volread(dir_path, 'DICOM')
vol_arr.shape

# Out[2]:
Reading DICOM (examining files): 1/99 files (1.0%99/99 files (100.0%)
  Found 1 correct series.
Reading DICOM (loading data): 31/99  (31.392/99  (92.999/99  (100.0%)

(99, 512, 512)

和第 4.1.3 节一样,这里的布局也不是 PyTorch 所期望的布局,因为它没有包含通道信息。因此,我们需要借助 unsqueeze 给它腾出一个通道维的位置:

ini 复制代码
# In[3]:
vol = torch.from_numpy(vol_arr).float()
vol = torch.unsqueeze(vol, 0)

vol.shape

# Out[3]:
torch.Size([1, 99, 512, 512])

注意 unsqueeze 会在指定位置新增一个大小为 1 的维度。这里,它把一个形状为 [D, H, W] 的三维数组,变成了一个形状为 [1, D, H, W] 的四维张量,其中第一个维度表示通道。

到这里,我们其实已经可以像上一节那样,通过沿 batch 方向堆叠多个体数据,来组装出一个五维数据集了。到了本书第二部分,我们还会看到大量 CT 数据。

4.3 表格数据的表示

在机器学习工作中,我们最常遇到的最简单数据形式,就是躺在电子表格、CSV 文件或数据库里的数据。不管载体是什么,本质上它都是一张表:每一行对应一个样本(或一条记录),而每一列则包含这个样本的某一项信息。

一开始,我们先假设表中的样本之间没有有意义的顺序。这样的表是一组彼此独立的样本集合;这和时间序列之类的数据不同,后者中的样本会通过时间这个维度彼此关联。

表中的列可能包含数值,例如某些地点的温度;也可能包含标签,例如用一个字符串表示样本的某个属性,比如 "blue(蓝色)"。因此,表格数据通常并不是同质的(homogeneous) :不同列的数据类型并不相同。比如,我们可能有一列记录苹果的重量,另一列则用标签来表示苹果的颜色。

而另一方面,PyTorch 张量是同质的。在 PyTorch 中,信息通常会被编码成数字,最常见的是浮点数(当然,也支持整数和布尔类型)。这种数值编码并不是偶然的,而是有意为之,因为神经网络本质上是数学对象:它们接收实数作为输入,并通过一系列矩阵乘法和非线性函数的连续作用,输出实数。

4.3.1 使用一个真实世界数据集

因此,作为深度学习实践者,我们的第一项工作,就是把这种异质的现实世界数据编码成一个由浮点数组成的张量,以便神经网络可以直接使用。互联网上有大量可以自由获取的表格数据集,例如可以参考:github.com/caesar0301/...

我们先从一个有趣一点的例子开始:葡萄酒!Wine Quality 数据集是一张可以自由获取的表格,其中包含了葡萄牙北部 vinho verde 白葡萄酒样本的化学特征,以及相应的感官质量评分。白葡萄酒版本的数据集可以从这里下载:mng.bz/90Ol。为了方便起见,我们也在本书的 Git 仓库中保存了一份副本,路径是 data/p1ch4/tabular-wine

这个文件是一组用逗号分隔的值,组织成 12 列,前面还有一行表头用于写列名。前 11 列是化学变量的数值,最后一列是从 0(非常差)到 10(非常优秀)的感官质量评分。以下是这些列的名称,顺序与数据集中出现的顺序一致:

c 复制代码
fixed acidity  
volatile acidity  
citric acid  
residual sugar  
chlorides  
free sulfur dioxide  
total sulfur dioxide  
density  
pH  
sulphates  
alcohol  
quality

在这个数据集上,一个可能的机器学习任务是:只根据化学特征来预测质量评分。不过别担心,机器学习暂时还不会让葡萄酒品鉴师失业------毕竟,训练数据总得有人先打出来!正如图 4.3 所示,我们希望能在数据中的某些化学指标列与 quality 列之间发现某种关系。这里,我们预期看到的是:随着硫含量降低,质量评分会升高。

图 4.3 葡萄酒中硫含量与质量之间的关系(我们希望如此)

4.3.2 加载葡萄酒数据张量

不过在此之前,我们得先把这些数据加载出来,以一种比"用文本编辑器直接打开文件"更可用的方式进行查看。下面我们来看,怎样用 Python 加载这份数据,并把它转成一个 PyTorch 张量。Python 提供了好几种快速加载 CSV 文件的方法,比较流行的有三种:

  • Python 自带的 csv 模块
  • NumPy
  • pandas

第三种方式在时间和内存效率上是最优的。不过,为了单纯加载一个文件而在学习路径中额外引入一个新库,并不是我们这里想做的。由于上一节我们已经介绍过 NumPy,而 PyTorch 对 NumPy 的互操作支持又非常出色,因此这里我们选择使用 NumPy。下面就来加载这个文件,并把得到的 NumPy 数组转成一个 PyTorch 张量(code/p1ch4/3_tabular_wine.ipynb)。

代码清单 4.4 加载 CSV 数据

ini 复制代码
# In[2]:
import csv
wine_path = "../data/p1ch4/tabular-wine/winequality-white.csv"
wineq_numpy = np.loadtxt(wine_path, dtype=np.float32, delimiter=";",
                         skiprows=1)
wineq_numpy

# Out[2]:
array([[ 7.  ,  0.27,  0.36, ...,  0.45,  8.8 ,  6.  ],
       [ 6.3 ,  0.3 ,  0.34, ...,  0.49,  9.5 ,  6.  ],
       [ 8.1 ,  0.28,  0.4 , ...,  0.44, 10.1 ,  6.  ],
       ...,
       [ 6.5 ,  0.24,  0.19, ...,  0.46,  9.4 ,  6.  ],
       [ 5.5 ,  0.29,  0.3 , ...,  0.38, 12.8 ,  7.  ],
       [ 6.  ,  0.21,  0.38, ...,  0.32, 11.8 ,  6.  ]], dtype=float32)

这里,我们显式规定了这个二维数组的数据类型应为 32 位浮点数,指定了每行内部值的分隔符为分号,并说明第一行不应读入,因为它包含的是列名。下面检查一下,看看所有数据是否都已经被正确读入:

css 复制代码
# In[3]:
col_list = next(csv.reader(open(wine_path), delimiter=';'))

wineq_numpy.shape, col_list

# Out[3]:
((4898, 12),
 ['fixed acidity',
  'volatile acidity',
  'citric acid',
  'residual sugar',
  'chlorides',
  'free sulfur dioxide',
  'total sulfur dioxide',
  'density',
  'pH',
  'sulphates',
  'alcohol',
  'quality'])

接下来,我们把这个 NumPy 数组转换成 PyTorch 张量:

ini 复制代码
# In[4]:
wineq = torch.from_numpy(wineq_numpy)

wineq.shape, wineq.dtype

# Out[4]:
(torch.Size([4898, 12]), torch.float32)

此时,我们已经有了一个浮点型的 torch.Tensor,其中包含了全部列,包括最后一列质量评分。

连续值、序数值与类别值

在真正理解数据之前,我们需要先意识到:数值型数据其实可以分成三种不同类型。

第一类是连续值(continuous values) 。这类值最符合我们对"数字"的直觉理解。它们具有严格顺序,而且不同数值之间的差异具有明确的数学意义。比如温度测量中,20°C 到 25°C 的差异,在物理上与 30°C 到 35°C 的差异是同样大小的热量变化。又比如身高测量中,相差 5 cm,不管是比较 150 cm 和 155 cm,还是比较 185 cm 和 190 cm,其物理意义都相同。

文献中还会把连续值进一步细分。如果说一个值是另一个值的"两倍"是有意义的,例如重量(10 千克的确是 5 千克的两倍重)或者距离(30 英里确实是 10 英里的三倍远),那么这类值被称为比率尺度(ratio scale) 。而摄氏温度虽然具有"差值一致"的性质,但说 20°C 是 10°C 的"两倍热"显然没有意义(因为 0°C 并不表示"零热量"),因此摄氏温度只属于区间尺度(interval scale)

第二类是序数值(ordinal values) 。它们仍然保留了像连续值那样的严格顺序,但不同数值之间的差异已经不再具有固定的数学关系。一个典型例子是饮料杯型:小杯、中杯、大杯,分别映射成 1、2、3。大杯的确比中杯大,就像 3 比 2 大一样;但这个表示本身并没有告诉我们"大多少"。如果我们把 1、2、3 换成实际容量(比如 8、12、24 液体盎司),那么它们就会变成区间值。需要特别记住的是:除了排序之外,我们不能对这些值直接"做数学运算";比如把大杯 = 3 和小杯 = 1 平均一下,并不会自动得到一个中杯!

最后一类是类别值(categorical values) 。这类值既没有顺序,也没有数值意义。它们通常只是对各种可能类别的一种枚举,并随意赋上数字。比如把 water(水)设为 1,coffee(咖啡)设为 2,soda(汽水)设为 3,milk(牛奶)设为 4,就是一个例子。把水排在第一、牛奶排在最后并没有任何真正逻辑;这些数字只是为了区分不同类别而已。我们完全可以把咖啡赋值为 10,把牛奶赋值为 -3,这在本质上并不会带来任何重要变化(尽管如果把值放在 0..N-1 范围内,会更方便后面要讲到的 one-hot 编码和第 4.5.4 节中的 embedding)。由于这些数字本身不承载任何意义,这类值被称为处在**名义尺度(nominal scale)**上。

4.3.3 表示评分

对于 quality 评分,我们既可以把它当作一个连续变量,保留为实数,并执行一个回归任务(regression) ;也可以把它当作一个标签,在分类任务(classification) 中尝试根据化学分析来猜测这个标签。无论采用哪种方式,通常我们都会把评分从输入数据张量中分离出来,单独放进另一个张量中,这样它就能作为 ground truth(真实标签)使用,而不会同时又作为模型输入:

ini 复制代码
# In[5]:
data = wineq[:, :-1]     #1
data, data.shape

# Out[5]:
(tensor([[ 7.00,  0.27,  ...,  0.45,  8.80],
         [ 6.30,  0.30,  ...,  0.49,  9.50],
         ...,
         [ 5.50,  0.29,  ...,  0.38, 12.80],
         [ 6.00,  0.21,  ...,  0.32, 11.80]]), torch.Size([4898, 11]))
perl 复制代码
# In[6]:
target = wineq[:, -1]     #2
target, target.shape

# Out[6]:
(tensor([6., 6.,  ..., 7., 6.]), torch.Size([4898]))
#1 选择所有行,以及除最后一列之外的所有列
#2 选择所有行,以及最后一列

如果我们想把 target 张量变成"标签张量",根据我们打算如何使用类别数据,可以有两种做法。第一种,就是简单地把标签视为一个整数分数组成的向量:

perl 复制代码
# In[7]:
target = wineq[:, -1].long()
target

# Out[7]:
tensor([6, 6,  ..., 7, 6])

如果 target 原本是字符串标签,例如葡萄酒颜色,那么只要为每个字符串分配一个整数,也可以采用同样的方法。

4.3.4 One-hot 编码

另一种方式,则是对评分构建一个 one-hot 编码 。也就是说,把每个评分编码成一个长度为 10 的向量:其中所有元素都为 0,只有一个位置为 1,而不同评分对应不同的位置。这样,评分 1 可以映射成向量 (1,0,0,0,0,0,0,0,0,0),评分 5 可以映射成 (0,0,0,0,1,0,0,0,0,0),等等。需要注意的是,"评分值恰好对应非零元素所在的索引"这件事本身并没有任何必然性:我们完全可以打乱这种映射方式,而从分类角度看,结果不会有任何变化。

这两种表示方式之间有着显著差异。把葡萄酒质量表示成整数值(1、2、3......)时,会保留两个数学属性:顺序性 (例如 4 分高于 1 分)以及等间距性(例如 1 到 3 的差与 2 到 4 的差是一样的)。如果这些属性确实准确反映了现实世界中的葡萄酒质量,那么这种表示就很合理。

不过,对于真正的类别型数据,例如葡萄品种(Merlot、Cabernet、Chardonnay),one-hot 编码就更合适,因为这些类别之间并没有自然顺序,也没有有意义的数值距离。对于某些离散等级型评分,one-hot 编码也很有用------比如质量只能是差、一般、优秀,而不存在合法的中间状态。在这种情况下,我们更希望表示方式清楚地说明一瓶酒明确属于某一类,而不是"介于几类之间"。

我们可以使用 scatter_ 方法来实现 one-hot 编码。这个方法会沿着给定索引,把源张量中的值填到目标张量中:

ini 复制代码
# In[8]:
target_onehot = torch.zeros(target.shape[0], 10)
target_onehot.scatter_(1, target.unsqueeze(1), 1.0)

random_indices = torch.randint(0, len(target), (5,))
print(target[random_indices])
print(target_onehot[random_indices])

# Out[8]:
5 random wine quality ratings (original):
tensor([6, 6, 7, 4, 5])

First 5 wine quality ratings (one-hot encoded):
tensor([[0., 0., 0., 0., 0., 0., 1., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 1., 0., 0., 0.],
        [0., 0., 0., 0., 0., 0., 0., 1., 0., 0.],
        [0., 0., 0., 0., 1., 0., 0., 0., 0., 0.],
        [0., 0., 0., 0., 0., 1., 0., 0., 0., 0.]])

我们来看看 scatter_ 到底做了什么。首先,注意它的名字以下划线结尾。正如你在上一章学到的,这是 PyTorch 的一个约定,表示该方法不会返回一个新张量,而是会原地修改张量本身。

scatter_ 的参数如下:

沿着哪一个维度应用索引。对于这个例子来说,维度 0 表示沿行分散索引,维度 1 表示沿列分散索引。

一个列张量,用来指明要写入的元素索引。

一个包含待写入元素的张量,或者一个单独的标量(这里是 1)。

换句话说,前面的调用可以读作:"对每一行来说,取出目标标签的索引(在这里刚好与评分值一致),并把这个索引用作列索引,把对应位置设为 1.0。" 最终结果就是一个用于表示类别信息的张量。

scatter_ 的第二个参数,也就是索引张量,要求与被写入的目标张量具有相同的维度数。由于 target_onehot 是二维的(4898 × 10),我们就必须用 unsqueezetarget 增加一个额外的虚拟维度:

ini 复制代码
# In[9]:
target_unsqueezed = target.unsqueeze(1)
target_unsqueezed

# Out[9]:
tensor([[6],
        [6],
        ...,
        [7],
        [6]])

这次 unsqueeze 调用增加了一个 singleton 维度:它把原本拥有 4898 个元素的一维张量,变成了一个大小为 (4898 × 1) 的二维张量,而内容本身完全没有改变。并没有真的新增任何元素;我们只是决定再多用一个索引来访问这些元素而已。也就是说,我们访问 target 的第一个元素时写作 target[0],而访问它经过 unsqueeze 后对应的第一个元素时,则写作 target_unsqueezed[0,0]

PyTorch 在训练神经网络时,允许我们直接把类别索引当作目标值使用。不过,如果我们想把评分作为一个类别型输入送进网络,那就必须先把它转换成 one-hot 编码张量。

4.3.5 什么时候该做类别化处理

现在,我们已经看过了处理连续数据和类别数据的办法。你可能会问:那序数数据该怎么办?对此并不存在一条通用公式。最常见的做法是:要么把它当作类别数据处理(这样就丢掉了顺序信息,只能希望如果类别数不多,模型在训练中也许能自己学出来);要么把它当作连续数据处理(但这又会人为引入一种任意的"距离"概念)。图 4.4 用一个小流程图总结了我们的数据映射方式。

图 4.4 如何处理连续、序数与类别型列

现在回到我们的 data 张量,它包含了与化学分析相关的 11 个变量。我们可以利用 PyTorch Tensor API 中的函数,对这些张量形式的数据进行操作。先来求出每一列的均值与方差:

ini 复制代码
# In[10]:
data_mean = torch.mean(data, dim=0)
data_mean

# Out[10]:
tensor([6.85e+00, 2.78e-01, 3.34e-01, 6.39e+00, 4.58e-02, 3.53e+01,
        1.38e+02, 9.94e-01, 3.19e+00, 4.90e-01, 1.05e+01])
ini 复制代码
# In[11]:
data_var = torch.var(data, dim=0)
data_var

# Out[11]:
tensor([7.12e-01, 1.02e-02, 1.46e-02, 2.57e+01, 4.77e-04, 2.89e+02,
        1.81e+03, 8.95e-06, 2.28e-02, 1.30e-02, 1.51e+00])

这里,dim=0 表示沿着第 0 维执行归约操作。到这一步,我们就可以通过"减去均值,再除以标准差"的方式,对数据做标准化。这通常有助于学习过程(第 5 章我们会更详细讨论这个话题):

ini 复制代码
# In[12]:
data_normalized = (data - data_mean) / torch.sqrt(data_var)
data_normalized

# Out[12]:
tensor([[ 1.72e-01, -8.18e-02,  ..., -3.49e-01, -1.39e+00],
        [-6.57e-01,  2.16e-01,  ...,  1.35e-03, -8.24e-01],
        ...,
        [-1.61e+00,  1.17e-01,  ..., -9.63e-01,  1.86e+00],
        [-1.01e+00, -6.77e-01,  ..., -1.49e+00,  1.04e+00]])

4.3.6 寻找阈值

接下来,我们开始带着一个问题来观察这份数据:有没有一种简单办法,可以让我们一眼大致区分"好酒"和"差酒"?首先,我们先找出 target 中那些评分小于等于 3 的行:

ini 复制代码
# In[13]:
bad_indexes = target <= 3       #1
bad_indexes.shape, bad_indexes.dtype, bad_indexes.sum()

# Out[13]:
(torch.Size([4898]), torch.bool, tensor(20))
#1 PyTorch 也提供比较函数,这里可写成 torch.le(target, 3),但直接用运算符显然更自然。

注意,bad_indexes 中只有 20 个位置是 True!利用 PyTorch 的一个特性------高级索引(advanced indexing) ------我们可以用一个 torch.bool 类型的张量去索引 data 张量。这样做的效果,本质上就是把数据过滤为只保留那些在索引张量中对应 True 的项(也就是行)。bad_indexes 的形状和 target 完全相同,其中的值要么是 False,要么是 True,具体取决于把我们的阈值与 target 中每个元素进行比较后的结果:

ini 复制代码
# In[14]:
bad_data = data[bad_indexes]
bad_data.shape

# Out[14]:
torch.Size([20, 11])

新的 bad_data 张量有 20 行,正好与 bad_indexes 中值为 True 的行数一致;同时它保留了全部 11 列。现在,我们就可以开始按"好、中、差"不同组别来分析葡萄酒数据了。先分别求出每一列的均值:

ini 复制代码
# In[15]:
bad_data = data[target <= 3]
mid_data = data[(target > 3) & (target < 7)]     #1
good_data = data[target >= 7]

bad_mean = torch.mean(bad_data, dim=0)
mid_mean = torch.mean(mid_data, dim=0)
good_mean = torch.mean(good_data, dim=0)

for i, args in enumerate(zip(col_list, bad_mean, mid_mean, good_mean)):
    print('{:2} {:20} {:6.2f} {:6.2f} {:6.2f}'.format(i, *args))

# Out[15]:
 0 fixed acidity          7.60   6.89   6.73
 1 volatile acidity       0.33   0.28   0.27
 2 citric acid            0.34   0.34   0.33
 3 residual sugar         6.39   6.71   5.26
 4 chlorides              0.05   0.05   0.04
 5 free sulfur dioxide   53.33  35.42  34.55
 6 total sulfur dioxide 170.60 141.83 125.25
 7 density                0.99   0.99   0.99
 8 pH                     3.19   3.18   3.22
 9 sulphates              0.47   0.49   0.50
10 alcohol               10.34  10.26  11.42
#1 对于布尔型 NumPy 数组和 PyTorch 张量,& 运算符表示逻辑"与"。

看起来我们似乎发现了一点东西:乍一看,差酒的总二氧化硫含量确实更高,此外还有其他一些差异。于是我们可以尝试用"总二氧化硫"的某个阈值,作为一个粗糙标准,来区分好酒和差酒。下面,我们把总二氧化硫这一列中低于刚刚算出的中间组均值的位置找出来:

ini 复制代码
# In[16]:
total_sulfur_threshold = 141.83
total_sulfur_data = data[:,6]
predicted_indexes = torch.lt(total_sulfur_data, total_sulfur_threshold)

predicted_indexes.shape, predicted_indexes.dtype, predicted_indexes.sum()

# Out[16]:
(torch.Size([4898]), torch.bool, tensor(2727))

这个阈值意味着:在全部葡萄酒中,略多于一半会被我们预测为高质量。接下来,我们需要找出"真正的好酒"对应的索引:

ini 复制代码
# In[17]:
actual_indexes = target > 5

actual_indexes.shape, actual_indexes.dtype, actual_indexes.sum()

# Out[17]:
(torch.Size([4898]), torch.bool, tensor(3258))

真正被归为好酒的数量,比我们阈值法预测出来的多了大约 500 瓶,因此我们已经有了明确证据:这个方法并不完美。接下来,我们要看的是:我们的预测究竟和真实评分吻合到了什么程度。我们将把预测索引与真实好酒索引做一次逻辑"与"运算(记住,它们本质上都只是由 0 和 1 组成的数组),利用这个"交集"来判断我们的表现:

ini 复制代码
# In[18]:
n_matches = torch.sum(actual_indexes & predicted_indexes).item()
n_predicted = torch.sum(predicted_indexes).item()
n_actual = torch.sum(actual_indexes).item()

n_matches, n_matches / n_predicted, n_matches / n_actual

# Out[18]:
(2018, 0.74000733406674, 0.6193984039287906)

我们猜对了大约 2000 瓶酒!由于我们一共预测了 2700 瓶酒为高质量,因此可以说:如果我们预测一瓶酒是高质量,那么它真的高质量的概率约为 74%。可惜的是,真实的好酒一共有 3200 多瓶,而我们只找出了其中的 61%。嗯,这结果正如它看起来那样------也就比随机猜稍微好一点而已!

当然,这一切都非常粗糙。我们几乎可以肯定,影响葡萄酒质量的不止一个变量;而这些变量的取值与最终结果之间的关系(而且结果原本还可以是实际评分,而不只是我们人为二值化之后的版本),很可能也远比"对单个变量设一个简单阈值"要复杂得多。

事实上,一个简单的神经网络就足以克服这些限制,很多其他基础机器学习方法也一样。等到接下来的两章学完、并且我们掌握了如何从零开始构建第一个神经网络之后,就会有工具真正去解决这个问题。现在,先继续看其他数据类型。

4.4 处理时间序列

在上一节中,我们讲了如何表示组织成平面表格的数据。正如当时提到的,表中的每一行都与其他行彼此独立;它们的顺序并不重要。换句话说,也可以说:表中并不存在某一列,用来编码"哪些行发生得更早,哪些行发生得更晚"这样的信息。

回到葡萄酒数据集,如果其中有一列 "year(年份)",我们就可以观察葡萄酒质量如何逐年变化。可惜我们手头没有这样的数据,因此我们切换到另一个很有意思的数据集:华盛顿特区一个共享单车系统的数据。这个数据集记录了 2011--2012 年 Capital Bikeshare 系统中每小时的自行车租赁数量,以及天气和季节信息(可在 mng.bz/jgOx 获取)。我们的目标,是把一个平面的二维数据集,转换成一个三维数据集,如图 4.5 所示。

图 4.5 通过新增一个表示日期的轴,把按小时组织的二维数据集变换成三维数据集。

4.4.1 增加时间维度

在原始数据中,每一行对应一个独立的小时。图 4.5 展示的是转置后的版本,这样更适合放在印刷页面上。我们想做的是:改变这种"每行表示一个小时"的组织方式,使得其中一个轴的索引每增加 1,就代表时间前进一天;另一个轴则表示一天中的小时(与具体日期无关)。第三个轴则用来表示不同的数据列(例如天气、温度等)。

先把数据加载进来(code/p1ch4/4_time_series_bikes.ipynb)。

代码清单 4.5 加载共享单车时间序列数据集

ini 复制代码
# In[2]:
bikes_numpy = np.loadtxt(
    "../data/p1ch4/bike-sharing-dataset/hour-fixed.csv",
    dtype=np.float32,
    delimiter=",",
    skiprows=1,
    converters={1: lambda x: float(x[8:10])})      #1
bikes = torch.from_numpy(bikes_numpy)
bikes

# Out[2]:
tensor([[1.0000e+00, 1.0000e+00,  ..., 1.3000e+01, 1.6000e+01],
        [2.0000e+00, 1.0000e+00,  ..., 3.2000e+01, 4.0000e+01],
        ...,
        [1.7378e+04, 3.1000e+01,  ..., 4.8000e+01, 6.1000e+01],
        [1.7379e+04, 3.1000e+01,  ..., 3.7000e+01, 4.9000e+01]])
#1 把第 1 列中的日期字符串转换成数字,也就是月份中的日

下面这段代码清单展示了列名以及数据中的一个示例行。

代码清单 4.6 hour-fixed.csv 中的数据示例

复制代码
instant,dteday,season,yr,mnth,hr,holiday,weekday,workingday,weathersit,
temp,atemp,hum,windspeed,casual,registered,cnt

1,2011-01-01,1,0,1,0,0,6,0,1,0.24,0.2879,0.81,0,3,13,16

对于每一个小时,数据集记录了如下变量:

  • 记录索引 ------ instant
  • 月份中的日期 ------ day
  • 季节 ------ season(1:春,2:夏,3:秋,4:冬)
  • 年份 ------ yr(0:2011,1:2012)
  • 月份 ------ mnth(1 到 12)
  • 小时 ------ hr(0 到 23)
  • 是否节假日 ------ holiday
  • 星期几 ------ weekday
  • 是否工作日 ------ workingday
  • 天气状况 ------ weathersit(1:晴;2:薄雾;3:小雨/小雪;4:大雨/大雪)
  • 气温(°C) ------ temp
  • 体感温度(°C) ------ atemp
  • 湿度 ------ hum
  • 风速 ------ windspeed
  • 临时用户数量 ------ casual
  • 注册用户数量 ------ registered
  • 租赁自行车总数 ------ cnt

在像这样的时间序列数据集中,各行表示的是连续的时间点:它们沿着某个维度天然具有顺序。诚然,我们完全可以把每一行都当作彼此独立的样本,尝试仅根据某个时刻的信息(例如一天中的具体时段)来预测当时流通中的自行车数量,而不管此前发生了什么。但正因为存在这种顺序性,我们就有机会去利用跨时间的因果关系。例如,我们可以基于更早时刻是否下雨,来预测后面某个时刻的骑行量。眼下,我们暂时先专注于一件事:学会如何把这份共享单车数据集整理成一种形式,使得神经网络可以按固定大小的块来吞进去。

神经网络模型理论上可以接受任意维度的数据,但如何把数据整理成一种直观、便于模型有效处理的形状,则需要由我们自己来决定。对于当前这个时间序列数据来说,把数据拆分成"天、列、小时"三个维度,是一个很合理的做法。下面就动手试试看!

4.4.2 按时间周期重塑数据

我们可以把这个跨两年的数据集切分成更宽一些的观察周期,比如按"天"来切。这样一来,我们就会得到 N(天数)组、每组包含 C(列数)条长度为 L(小时数)的序列。换句话说,我们的时间序列数据集就会变成一个三维张量,形状为 N × C × L。其中,C 仍然是原始数据集中的那 17 个变量,而 L 则固定为一天 24 个小时。

你也许会问:为什么我们不把它组织成 N × L × C,也就是"天 × 小时 × 变量"呢?这种排列当然也是合法的,但它会把变量数据拆散到各个列中,使得每一行表示"某一个具体小时点上的全部变量"(见图 4.6)。而在这个练习里,我们希望把每一个独立变量(例如温度、湿度等)保持为一整条连续序列,这样就可以把它们作为完整的一行来访问,因此我们保留 N × C × L 这种布局。

图 4.6 N × C × L(左)与 N × L × C(右)两种维度排列的可视化

此外,也没有任何硬性规定说我们一定要按 24 小时来切块,尽管日周期本身很可能包含有利于预测的模式。我们也完全可以按周来切,也就是用 7 × 24 = 168 小时的块来构造数据。这一切当然取决于数据集本身的大小是否合适------换句话说,总行数必须是 24 或 168 的整数倍。而且,如果时间序列中存在缺失时段,这种做法也就说不通了。

现在回到我们的共享单车数据集。第一列是索引(也就是数据的全局顺序),第二列是日期,第六列是一天中的具体时间。我们已经拥有了构建"按天组织的骑行数量及其他外生变量序列数据集"所需的一切信息。我们的数据集本身已经排好序了;如果没有的话,也可以使用 torch.sort 来完成排序。

注意 我们这里使用的文件版本 hour-fixed.csv 经过了一些处理,补上了原始数据集中缺失的行。我们推测这些缺失的小时,原本的自行车活动数量为 0(它们通常出现在凌晨时段)。

为了得到按天组织的小时数据集,我们需要把同一份张量重新"看成"一批一批的 24 小时。先来看看 bikes 张量的形状和步幅:

css 复制代码
# In[3]:
bikes.shape, bikes.stride()

# Out[3]:
(torch.Size([17520, 17]), (17, 1))

也就是说,这里共有 17,520 个小时、17 列变量。现在,我们把数据 reshape 成三个轴:天、小时,以及那 17 列变量:

ini 复制代码
# In[4]:
daily_bikes = bikes.view(-1, 24, bikes.shape[1])
daily_bikes.shape, daily_bikes.stride()

# Out[4]:
(torch.Size([730, 24, 17]), (408, 17, 1))

这里到底发生了什么?首先,bikes.shape[1] 就是 17,也就是 bikes 张量的列数。但这段代码真正的关键,在于那个非常重要的 view 调用:它改变了张量"看待同一块 storage 的方式"。

正如你在上一章中学到的,对一个张量调用 view,会返回一个新张量;这个新张量改变了维度数以及步幅信息,但不会改变底层 storage 。因此,我们几乎可以零成本地重新组织张量,因为根本不会发生数据复制。view 的调用要求我们为返回张量提供一个新的形状。这里使用 -1,表示"在已知其他维度和原始元素总数的前提下,自动推断剩下还需要多少个索引"。

还记得上一章里讲过:storage 是一个连续、线性的数字容器(在这里是浮点数)。我们的 bikes 张量会把每一行按顺序一个接一个地存放到对应的 storage 中;前面 bikes.stride() 的输出已经验证了这一点。

对于 daily_bikes 来说,它的 stride 告诉我们:沿着小时维(第二维)前进一步,就需要在 storage 中跳过 17 个位置(也就是一整组列);而沿着天这个维度(第一维)前进一步,则要跳过一整天的数据,也就是"一行长度 × 24",在这里就是 408(即 17 × 24)。

我们看到,最右边那个维度就是原始数据集中的列数;中间那个维度则是时间,只不过现在被切分成了按 24 小时为一组的连续块。换句话说,现在我们有了 N 个长度为 L(一天 24 小时)的序列,每个序列有 C 个通道。为了得到我们真正想要的 N × C × L 排列,我们需要再对张量做一次转置:

ini 复制代码
# In[5]:
daily_bikes = daily_bikes.transpose(1, 2)
daily_bikes.shape, daily_bikes.stride()

# Out[5]:
(torch.Size([730, 17, 24]), (408, 1, 17))

现在,我们就可以把前面学过的一些技巧应用到这个数据集上了。

4.4.3 为训练做好准备

其中的 "weather situation(天气状况)" 变量是一个序数变量。它有四个等级:1 表示好天气,4 表示,呃,非常糟糕的天气。我们既可以把这个变量当作类别变量来处理,把不同等级看作标签;也可以把它当作连续变量来处理。如果决定按类别方式处理,我们就需要把这个变量转换成 one-hot 编码向量,并把这些新列拼接回原始数据集中。

注意 这个场景其实也可以算是一个值得"跳出主路"多想一步的地方。我们也许可以尝试一种更直接体现有序性的类别表示方式,比如把这里四个类别中的第 i 类,映射成一个向量:在位置 0 ... i 上全为 1,之后的位置全为 0,也就是对 one-hot 做某种推广。又或者,类似第 4.5.4 节里会讲到的 embedding,我们也可以使用 embedding 的部分和;在那种情况下,也许让这些值保持为正会更合理。像实践工作中常见的很多情况一样,这里也可能是一个"先借鉴别人已证明有效的方法,再系统化实验"的典型场景。

为了更方便展示数据,我们暂时先只看第一天。我们先初始化一个全 0 矩阵:它的行数等于这一天中的小时数,列数等于天气等级的数量:

ini 复制代码
# In[6]:
first_day = bikes[:24].long()
weather_onehot = torch.zeros(first_day.shape[0], 4)
first_day[:,9]

# Out[6]:
tensor([1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 2, 2,
        2, 2])

然后,我们根据每一行对应的天气等级,把 1 散射(scatter)到这个矩阵中。注意,这里和前面几节一样,也会用到 unsqueeze 来增加一个 singleton 维度:

ini 复制代码
# In[7]:
weather_onehot.scatter_(
    dim=1,
    index=first_day[:,9].unsqueeze(1).long() - 1,    #1
    value=1.0)

# Out[7]:
tensor([[1., 0., 0., 0.],
        [1., 0., 0., 0.],
        ...,
        [0., 1., 0., 0.],
        [0., 1., 0., 0.]])
#1 这里把值减去 1,是因为天气状况的取值范围是 1 到 4,而索引是从 0 开始的

我们这一天开始时的天气是 1,结束时是 2,看起来是对的。

最后,我们使用 cat 函数,把这个矩阵拼接到原始数据集后面。先看看结果中的第一条:

ini 复制代码
# In[8]:
torch.cat((bikes[:24], weather_onehot), 1)[:1]

# Out[8]:
tensor([[ 1.0000,  1.0000,  1.0000,  0.0000,  1.0000,  0.0000,  0.0000,
          6.0000,  0.0000,  1.0000,  0.2400,  0.2879,  0.8100,  0.0000,
          3.0000, 13.0000, 16.0000,  1.0000,  0.0000,  0.0000,  0.0000]])

这里,我们要求将原始的 bikes 数据集与 one-hot 编码后的"天气状况"矩阵沿列维度(也就是维度 1)进行拼接。换句话说,这两个数据集的列被堆在了一起;也可以说,新的 one-hot 编码列被追加到了原始数据集之后。为了让 cat 成功执行,这两个张量在其他维度上的大小必须一致------在这里,也就是行维度必须一致。注意,我们新得到的最后四列是 1, 0, 0, 0,这正好符合天气值为 1 时应有的结果。

我们也可以对已经 reshape 好的 daily_bikes 张量做同样的处理。记住,它的形状是 (B, C, L),其中 L = 24。我们先创建一个零张量,它与 daily_bikes 拥有相同的 BL,但在新增列的数量上取代原本的 C

ini 复制代码
# In[9]:
daily_weather_onehot = torch.zeros(daily_bikes.shape[0], 4,
                                   daily_bikes.shape[2])
daily_weather_onehot.shape

# Out[9]:
torch.Size([730, 4, 24])

然后,沿着 C 这个维度,把 one-hot 编码散射进张量中。由于这是一个原地操作,因此变化的只有张量内容本身:

css 复制代码
# In[10]:
daily_weather_onehot.scatter_(
    1, daily_bikes[:,9,:].long().unsqueeze(1) - 1, 1.0)
daily_weather_onehot.shape

# Out[10]:
torch.Size([730, 4, 24])

接着,再沿着 C 维把它们拼接起来:

ini 复制代码
# In[11]:
daily_bikes = torch.cat((daily_bikes, daily_weather_onehot), dim=1)

前面提到过,这并不是处理"天气状况"变量的唯一方法。事实上,它的标签之间本来就有序,因此我们也可以把它们看作某种特殊的连续变量取值。比如,可以把这个变量缩放到 0.0 到 1.0 之间:

ini 复制代码
# In[12]:
daily_bikes[:, 9, :] = (daily_bikes[:, 9, :] - 1.0) / 3.0

正如前一节中说过的,把变量重新缩放到 [0.0, 1.0][-1.0, 1.0] 区间,是我们通常希望对所有定量变量都做的事情,例如温度(在这个数据集中是第 10 列)。我们稍后会看到原因;现在只需知道:这通常对训练过程是有益的。

变量的重缩放方式有很多种。我们既可以把它们的取值范围映射到 [0.0, 1.0]

ini 复制代码
# In[13]:
temp = daily_bikes[:, 10, :]
temp_min = torch.min(temp)
temp_max = torch.max(temp)
daily_bikes[:, 10, :] = ((daily_bikes[:, 10, :] - temp_min)
                         / (temp_max - temp_min))

也可以减去均值,再除以标准差:

ini 复制代码
# In[14]:
temp = daily_bikes[:, 10, :]
daily_bikes[:, 10, :] = ((daily_bikes[:, 10, :] - torch.mean(temp))
                         / torch.std(temp))

在后一种情况下,这个变量会具有零均值和单位标准差。如果这个变量原本服从高斯分布,那么其中大约 68% 的样本会落在 [-1.0, 1.0] 区间内。

很好,我们又构建出了一个不错的数据集,也看到该如何处理时间序列数据了。对于这次全景式概览来说,重要的是:我们对时间序列的数据布局有了一个直观认识,也看到了如何把它整理成神经网络能够消化的形式。

还有其他一些数据类型,也很像时间序列,因为它们同样具有严格顺序。排在前两位的是什么?文本音频。接下来我们会先看文本,而本章最后一节还会给出更多音频示例的链接。

4.5 文本的表示

深度学习已经席卷了自然语言处理(NLP)领域,尤其是那些反复接收"新输入 + 先前模型输出"组合的模型。这类模型被称为循环神经网络(RNN) ,它们已经在文本分类、文本生成以及自动翻译系统中取得了巨大成功。近些年,一类名为 transformer(变换器) 的网络因为提供了更灵活地利用历史信息的方式,也引起了巨大轰动。相比之下,早期的 NLP 工作流通常是复杂的多阶段流水线,其中甚至还会包含显式编码语言语法规则的模块(参见 Nadkarni 等人的 "Natural Language Processing: An Introduction",mng.bz/8pJP)。

而现在,最先进的方法则是从零开始,在大规模语料库上对网络进行端到端训练,让那些规则直接从数据中"长出来"。过去几年里,互联网上最常见的自动翻译在线服务,已经基本都是建立在深度学习之上的。

本节的目标,是把文本变成神经网络能够处理的形式------也就是一个数字张量,就像前面那些例子一样。只要我们能够做到这一点,并在后续为具体文本处理任务选对合适的架构,那么我们就具备了用 PyTorch 做 NLP 的基础。这里立刻就能看出这种方法的强大之处:借助同样一套 PyTorch 工具,我们可以在不同领域的许多任务上实现最先进的表现;我们所需要做的,只是把问题转换成正确的形式。而这项工作的第一部分,就是重塑数据。

4.5.1 把文本转换成数字

网络处理文本,最直观的有两个层次:一个是字符级(character level) ,也就是一次处理一个字符;另一个是词级(word level) ,即把单个词当作网络可见的最细粒度单位。无论我们是在字符级还是词级上操作文本,把文本信息编码成张量的技术其实是一样的。它也并不神秘------我们前面已经见过了:one-hot 编码

先从一个字符级例子开始。首先,我们需要一些文本来处理。这里一个非常棒的资源是 Project Gutenbergwww.gutenberg.org/),它是一个志愿者项目,致力于将文化作品数字化、归档,并以开放格式(包括纯文本文件)免费提供出来。如果目标是更大规模的语料,那么Wikipedia 语料库 也非常突出:它包含全部维基百科文章,总计 19 亿个单词、超过 440 万篇文章。其他许多语料库也可以在 English Corpora 网站上找到(www.english-corpora.org/)。

下面我们来加载 Project Gutenberg 上的 Jane Austen《傲慢与偏见》:www.gutenberg.org/files/1342/...。我们先把这个文件保存下来,再读入(code/p1ch4/5_text_jane_austen.ipynb)。

代码清单 4.7 读取文本文件

csharp 复制代码
# In[2]:
with open('../data/p1ch4/jane-austen/1342-0.txt', encoding='utf8') as f:
    text = f.read()

4.5.2 对字符做 one-hot 编码

在继续之前,还有一个细节需要先处理:编码(encoding) 。编码本身是一个很大的主题,这里我们只稍微碰一下。每一个书写字符都由某种编码来表示------也就是由一串足够长的比特位组成,以确保每个字符都能被唯一识别。最简单的一种编码是 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码) ,它可以追溯到 20 世纪 60 年代。ASCII 用 128 个整数来编码 128 个字符。例如,字母 a 对应二进制 1100001,也就是十进制的 97;字母 b 对应二进制 1100010,也就是十进制的 98,以此类推。整个编码可以装进 8 位里,而在 1965 年,这可是一个不小的优势。

注意 很显然,128 个字符远远不足以表示英语之外各种语言所需的全部字形、重音符号、连字等等。为此,人们后来发展出了多种使用更多比特位的编码方式,以支持更广泛的字符集合。这个更大的字符集合后来被标准化为 Unicode:它把所有已知字符都映射成数字,而这些数字具体如何表示成比特位,则交由某种特定编码来决定。常见的编码包括 UTF-8、UTF-16 和 UTF-32,它们分别把这些数字表示成一串 8 位、16 位或 32 位整数。Python 3.x 中的字符串本质上就是 Unicode 字符串。

接下来,我们要对字符做 one-hot 编码。一个非常重要的前提是:我们应当把 one-hot 编码限制在对当前文本真正有用的字符集上。就我们的例子而言,因为加载的是英语文本,所以安全地使用 ASCII 这种较小的编码空间是没问题的。我们也可以进一步把所有字符统一转成小写,以减少编码中不同字符的数量。同理,我们还可以过滤掉标点、数字,或者其他那些与我们预期文本类型无关的字符。至于这种处理是否会对神经网络带来实际影响,则取决于具体任务本身。

到了这一步,我们需要遍历文本中的字符,并为每个字符提供一个 one-hot 编码。每个字符都会由一个向量表示,其长度等于编码空间中不同字符的总数。这个向量里除了与该字符在编码中位置对应的索引处为 1 之外,其余位置都为 0。

我们先把整篇文本拆成一个由行组成的列表,并随意挑一行作为例子:

ini 复制代码
# In[3]:
lines = text.split('\n')
line = lines[200]
line

# Out[3]:
'"Impossible, Mr. Bennet, impossible, when I am not acquainted with him'

现在创建一个张量,用来容纳这整行中所有字符的 one-hot 编码:

ini 复制代码
# In[4]:
letter_t = torch.zeros(len(line), 128)    #1
letter_t.shape

# Out[4]:
torch.Size([70, 128])
#1 这里把 128 写死,是因为 ASCII 的范围就是 128 个字符

letter_t 中的每一行都对应一个经过 one-hot 编码的字符。现在,我们需要在每一行的正确位置写入一个 1,让这一行能够准确表示相应字符。这个 1 应该写到的位置,就是该字符在编码中的索引:

less 复制代码
# In[5]:
for i, letter in enumerate(line.lower().strip()):
    letter_index = ord(letter) if ord(letter) < 128 else 0     #1
    letter_t[i][letter_index] = 1
#1 这段文本里用了方向型双引号,它们不属于有效的 ASCII 字符,所以这里直接把它们过滤掉

4.5.3 对整词做 one-hot 编码

现在,我们已经把一句话编码成了神经网络可以直接处理的形式。词级编码其实可以用完全相同的方法来完成:先建立一个词汇表(vocabulary),然后把句子(也就是单词序列)沿着张量的行方向做 one-hot 编码。不过由于词汇表往往很大,这种编码会产生非常宽的向量,现实中往往不太实用。下一节我们会看到:在词级表示文本时,还有一种更高效的方式,叫做 embedding(嵌入) 。现在,先继续使用 one-hot 编码,看看会发生什么。

我们定义一个 clean_words 函数:它接收一段文本,把它转换成小写,并去掉标点。把它应用到刚才那句 "Impossible, Mr. Bennet" 上,得到的是:

arduino 复制代码
# In[6]:
def clean_words(input_str):
    punctuation = '.,;:"!?""_-'
    word_list = input_str.lower().replace('\n',' ').split()
    word_list = [word.strip(punctuation) for word in word_list]
    return word_list

words_in_line = clean_words(line)
line, words_in_line

# Out[6]:
('"Impossible, Mr. Bennet, impossible, when I am not acquainted with him',
 ['impossible',
  'mr',
  'bennet',
  'impossible',
  'when',
  'i',
  'am',
  'not',
  'acquainted',
  'with',
  'him'])

接下来,我们为编码建立一个"唯一单词到索引"的映射:

css 复制代码
# In[7]:
word_list = sorted(set(clean_words(text)))
word2index_dict = {word: i for (i, word) in enumerate(word_list)}

len(word2index_dict), word2index_dict['impossible']

# Out[7]:
(7261, 3394)

此时,word2index_dict 已经是一个字典:键是单词,值是整数。稍后在 one-hot 编码单词时,我们会用它快速找到某个单词所对应的索引。现在回到我们的句子:先把它拆成单词,再对它做 one-hot 编码。也就是说,我们要创建一个张量,其中每一行都是一句话中某个单词的 one-hot 向量。我们先创建一个空向量,再把句子里各个单词的 one-hot 值填进去:

less 复制代码
# In[8]:
word_t = torch.zeros(len(words_in_line), len(word2index_dict))
for i, word in enumerate(words_in_line):
    word_index = word2index_dict[word]
    word_t[i][word_index] = 1
    print('{:2} {:4} {}'.format(i, word_index, word))

print(word_t.shape)


# Out[8]:
 0 3394 impossible
 1 4305 mr
 2  813 bennet
 3 3394 impossible
 4 7078 when
 5 3315 i
 6  415 am
 7 4436 not
 8  239 acquainted
 9 7148 with
10 3215 him
torch.Size([11, 7261])

到这一步,这个张量就表示了一句长度为 11 的句子,而它的编码空间大小是 7261,也就是我们词典中的单词数。图 4.7 对比了我们目前为止看到的两种文本切分与表示方式(以及下一节将讲到的 embedding)。

图 4.7 编码一个单词的三种方式

在字符级编码和词级编码之间,我们必须做一个权衡。在很多语言里,字符数远少于单词数:用字符表示时,我们只需要处理少数几个类别;而用单词表示时,则必须面对数量庞大的类别集合,并且在任何现实应用中,都还要应对词典之外的新词。另一方面,单词本身承载的信息量远大于单个字符,因此单词表示天然更加有信息量。考虑到这两种方式之间的鲜明对比,人们后来去寻找某种"中间路线",也就一点都不奇怪了------而且这种路线已经被找到,并且获得了巨大成功。举例来说,Byte Pair Encoding(BPE) 方法会从一个只包含单个字母的词典开始,然后不断把语料中最频繁出现的字母对加入词典,直到达到预定的词典规模。这样一来,我们的示例句子就可能会被切分成类似如下的 token:

perl 复制代码
▁Im|pos|s|ible|,|▁Mr|.|▁B|en|net|,|▁impossible|,|▁when|▁I|▁am|▁not|▁acquainted|▁with|▁him

注意 Byte Pair Encoding 通常由 subword-nmtSentencePiece 这类库来实现。它在概念上的一个缺点是:一个字符序列的表示方式不再是唯一的。(这里的示例来自一个在机器翻译数据集上训练得到的 SentencePiece tokenizer。)

对于大多数词而言,我们的映射方式基本上还是按单词切分;但对那些更少见的部分------例如大写开头的 Impossible 和专有名 Bennet------则会进一步拆成子单元。

4.5.4 文本嵌入

one-hot 编码是一种非常有用的技术,用于在张量中表示类别数据。不过,正如前面已经预告过的,当需要编码的项目数量事实上没有上限时------比如语料库里的单词------one-hot 编码就开始显得力不从心了。仅仅一本书,我们就已经有了 7000 多个不同单词!

当然,我们完全可以做一些额外工作:比如去重、合并不同拼写形式、把过去时和将来时归并成一个 token,等等。但即便如此,一个通用英语词汇编码的规模仍然会非常巨大。更糟糕的是,每当我们遇到一个新词,就必须给向量再增加一列;这意味着模型中也必须增加一组新权重来适配这个新词表项,而从训练角度看,这会非常痛苦。

那我们怎么才能把这种编码压缩到一个更可控的大小,并给它的增长规模加上一个上限呢?办法是:不用"很多个 0 加上一个 1"这样的向量,而改用浮点数向量 。比如,一个由 100 个浮点数组成的向量,就已经可以表示非常大量的单词。诀窍在于,要找到一种有效的方法,把单个单词映射到这个 100 维空间里,并且这种映射还得有利于后续学习。这就叫作 embedding(嵌入)

理论上,我们完全可以遍历整个词汇表,为每个单词随便生成一组 100 维的随机浮点数。这当然"能用"------因为确实可以把一个很大的词汇表压进 100 个数字里------但这样做完全放弃了基于词义或上下文的距离概念。用这种词嵌入作为输入时,模型几乎无法从输入向量中感受到任何结构。理想情况下,我们希望 embedding 的生成方式能够保证:在相似上下文中使用的词,会被映射到 embedding 空间中的相邻区域。

如果我们想手工设计一个这样的方案,也许会决定:选择一些基础名词和形容词,作为 embedding 空间的轴。比如,构造一个二维空间:其中一个轴表示名词类别------fruit(水果,0.0--0.33)、flower(花,0.33--0.66)、dog(狗,0.66--1.0);另一个轴表示形容词------red(红,0.0--0.2)、orange(橙,0.2--0.4)、yellow(黄,0.4--0.6)、white(白,0.6--0.8)、brown(棕,0.8--1.0)。我们的目标,就是把真实存在的水果、花和狗摆放进这个 embedding 中。

当我们开始嵌入单词时,可以把 apple 映射到"水果 + 红色"象限附近。同样地,我们也很容易放入 tangerine、lemon、lychee 和 kiwi(让这份彩色水果列表更完整一点)。然后再开始处理花,把 rose、poppy、daffodil、lily......嗯,可棕色花并不多。那好吧,sunflower 可以同时带有 flower、yellow 和 brown,而 daisy 则可以是 flower、white 和 yellow。也许我们还应该把 kiwi 调整到更接近 fruit、brown 和 green 的位置。(当然,在我们这个一维颜色轴设定里,这其实做不到,因为 sunflower 的黄色和棕色平均之后会跑到白色附近------不过你应该已经理解这个意思了;在更高维度下,这种方法会好得多。)至于狗和颜色,我们可以把 redbone 放到接近 red 的位置;orange 这边也许可以用 fox(呃,虽然不是狗);yellow 附近放 golden retriever;white 附近放 poodle;brown 则有很多犬种都可以放进去。

这样一来,我们的 embedding 大致就会像图 4.8 那样。虽然手工编码在大语料上显然不可行,但在这里只用 2 维 embedding,我们已经能表示除基础 8 个概念之外的另外 15 个单词,而且如果再多花点心思,其实还可以塞进更多。

图 4.8 我们手工构造的词嵌入

你大概已经猜到了,这样的工作完全可以自动化。通过处理大规模自然文本语料,就可以生成与我们刚才所描述的 embedding 类似的表示。主要区别在于:真正的 embedding 向量通常有 100 到 1000 个元素,而且各个轴并不直接对应某个清晰可解释的概念;相反,语义上相近的单词会被映射到 embedding 空间中相邻的区域,而这个空间的各个轴本质上只是任意的浮点维度。

至于具体算法(例如 word2vec,参见 code.google.com/archive/p/w...)究竟怎么实现,已经有点超出我们这里关注的重点了。不过还是值得一提:embedding 通常也是借助神经网络生成的,方法往往是让网络根据一个词在句子中的上下文(即周围单词)去预测这个词本身。在这种情况下,我们可以从 one-hot 编码的单词出发,借助一个(通常相对较浅的)神经网络来生成 embedding。一旦有了这个 embedding,我们就可以把它用于各种下游任务。

一个很有意思的现象是:最终得到的 embedding 中,相似的单词不仅会聚集在一起,而且它们与其他单词之间还会形成相对稳定的空间关系。比如,如果我们拿到 apple 的 embedding 向量,再去加减其他词向量,就可能做出这样的类比:apple - red - sweet + yellow + sour,最终得到一个非常接近 lemon 的向量。

而现在更先进的 embedding 模型------像 BERT 和 GPT,甚至都已经在主流媒体里频频出现------则要复杂得多,也更依赖上下文。也就是说,词汇表中的某个单词映射到什么向量,不再是固定不变的,而取决于它所在的句子上下文。不过,在很多实际应用中,它们依然会像我们刚才讨论的这种经典 embedding 一样被使用。

4.5.5 以文本嵌入为蓝图

当词汇表中有大量条目需要用数值向量表示时,embedding 是一种非常关键的工具。其实,文本的表示与处理,也可以被看作是处理一般类别数据的一种范例。只要 one-hot 编码开始显得笨重,embedding 就会变得非常有用。事实上,以我们前面描述的形式来看,embedding 本质上就是:先做 one-hot 编码,然后立刻与一个"每行都是 embedding 向量"的矩阵相乘,而 embedding 只是把这个过程做得更高效。

在非文本应用里,我们通常没有能力事先构造好 embedding,于是就会从我们前面嫌弃过的那种"随机浮点数向量"开始,并把"如何让它变得更好"纳入整个学习问题的一部分。这种做法已经非常标准,以至于 embedding 已经成为几乎所有类别型数据中 one-hot 编码的重要替代方案。反过来说,即便在处理文本时,对那些预训练好的 embedding 再根据当前任务继续优化,也已经成为非常常见的实践。这一过程就叫作 fine-tuning(微调)

当我们关心的是观测之间的共现关系时,前面看到的词嵌入其实也能作为一种蓝图。例如在推荐系统中------比如"喜欢本书的读者还购买了图书 X"------顾客已经交互过的物品,就可以被视为上下文,用来预测接下来还有什么会引发兴趣。类似地,文本处理本身大概是最常见、也最成熟的序列处理任务。因此,当我们处理时间序列之类的问题时,也完全可以从 NLP 中借鉴灵感。

4.6 结语

这一章我们已经覆盖了很多内容。我们学习了如何加载最常见的几种数据类型,并把它们整理成神经网络能够使用的形式。当然,现实世界中的数据格式,远比我们能在一本书里全部讲完的多。有些数据,例如病历记录,复杂到不适合在这里展开;另一些数据,比如音频和视频,则因为对本书主线来说没那么关键,而没有作为重点介绍。

不过,如果你对这些内容感兴趣,我们在本书网站(www.manning.com/books/deep-...)以及代码仓库(github.com/deep-learni...)中,额外提供了关于音频和视频张量构建的简短 Jupyter Notebook 示例。

现在,既然我们已经熟悉了张量,也知道了如何把数据存进张量里,就可以迈向本书目标的下一步了:教你如何训练深度神经网络!下一章将会介绍简单线性模型中的学习机制。

4.7 练习

用手机或其他数码相机拍几张红色、蓝色和绿色物体的照片(如果没有相机,也可以从网上下载一些):

  • 加载每张图像,并把它转换成张量。
  • 对每张图像张量,使用 .mean() 方法感受一下图像整体有多亮。
  • 分别计算图像中每个通道的平均值。仅凭各通道平均值,你能分辨出哪些是红色、绿色和蓝色物体吗?

找一个相对较大的 Python 源代码文件:

  • 为源代码文件中的所有单词建立一个索引(你可以让 tokenization 规则尽可能简单,也可以尽可能复杂;我们建议从把 r"[^a-zA-Z0-9_]+" 替换成空格开始)。
  • 把你建立的索引,与我们为《傲慢与偏见》建立的索引作比较。哪一个更大?
  • 为这个源代码文件构建 one-hot 编码。
  • 这种编码丢失了哪些信息?这些信息与《傲慢与偏见》的编码中丢失的信息相比,有什么异同?

小结

  • 神经网络要求数据以多维数值张量的形式表示,通常是 32 位浮点数。

  • 一般来说,PyTorch 希望数据沿着特定维度进行排列,而具体排列方式取决于模型架构------例如卷积网络与循环网络的要求就不同。借助 PyTorch 的张量 API,我们可以高效地重塑数据。

  • PyTorch 与 Python 生态集成得很好,因此很容易加载常见数据类型并把它们转换成张量。

  • 图像可以具有一个或多个通道。最常见的是普通数字照片中的红、绿、蓝三个通道。

  • 许多图像每个通道的位深是 8 位,但 12 位和 16 位每通道也并不罕见。这些位深都可以无损地存入 32 位浮点数中。

  • 单通道数据格式(例如灰度图像)有时会省略显式的通道维。

  • 体数据和二维图像数据非常相似,只不过多了第三个维度(深度)。

  • 把电子表格转换成张量通常非常直接。类别型和序数型列,应当与区间型列区别对待。

  • 文本或类别型数据可以借助字典转换成 one-hot 表示;而在很多情况下,embedding 会提供更高效、更实用的表示方式。

相关推荐
数据智能老司机5 小时前
PyTorch 深度学习——它始于一个张量
pytorch·深度学习
yiyu07161 天前
3分钟搞懂深度学习AI:自我进化的最简五步法
人工智能·深度学习
yiyu07162 天前
3分钟搞懂深度学习AI:反向传播:链式法则的归责游戏
人工智能·深度学习
CoovallyAIHub2 天前
语音AI Agent编排框架!Pipecat斩获10K+ Star,60+集成开箱即用,亚秒级对话延迟接近真人反应速度!
深度学习·算法·计算机视觉
Narrastory2 天前
明日香 - Pytorch 快速入门保姆级教程(三)
pytorch·深度学习
yiyu07163 天前
3分钟搞懂深度学习AI:梯度下降:迷雾中的下山路
人工智能·深度学习
CoovallyAIHub3 天前
Moonshine:比 Whisper 快 100 倍的端侧语音识别神器,Star 6.6K!
深度学习·算法·计算机视觉
vivo互联网技术3 天前
ICLR2026 | 视频虚化新突破!Any-to-Bokeh 一键生成电影感连贯效果
人工智能·python·深度学习