PyTorch 深度学习(GPT 重译)(一)(3)https://developer.aliyun.com/article/1485200
我们不能使用两个索引索引二维张量的存储。存储的布局始终是一维的,而不管可能引用它的任何和所有张量的维度如何。
在这一点上,改变存储的值导致改变其引用张量的内容应该不会让人感到意外:
# In[20]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) points_storage = points.storage() points_storage[0] = 2.0 points # Out[20]: tensor([[2., 1.], [5., 3.], [2., 1.]])
3.7.2 修改存储的值:原地操作
除了前一节介绍的张量操作外,还存在一小部分操作仅作为Tensor
对象的方法存在。它们可以通过名称末尾的下划线识别,比如zero_
,表示该方法通过修改输入来原地操作,而不是创建新的输出张量并返回它。例如,zero_
方法将所有输入元素都置零。任何没有末尾下划线的方法都不会改变源张量,并且会返回一个新的张量:
# In[73]: a = torch.ones(3, 2) # In[74]: a.zero_() a # Out[74]: tensor([[0., 0.], [0., 0.], [0., 0.]])
3.8 张量元数据:大小、偏移和步长
为了索引到存储中,张量依赖于一些信息,这些信息与它们的存储一起,明确定义它们:尺寸、偏移和步幅。它们的相互作用如图 3.5 所示。尺寸(或形状,在 NumPy 术语中)是一个元组,指示张量在每个维度上代表多少个元素。存储偏移是存储中对应于张量第一个元素的索引。步幅是在存储中需要跳过的元素数量,以获取沿每个维度的下一个元素。
图 3.5 张量的偏移、尺寸和步幅之间的关系。这里的张量是一个更大存储的视图,就像在创建更大的张量时可能分配的存储一样。
3.8.1 另一个张量存储的视图
通过提供相应的索引,我们可以获取张量中的第二个点:
# In[21]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) second_point = points[1] second_point.storage_offset() # Out[21]: 2 # In[22]: second_point.size() # Out[22]: torch.Size([2])
结果张量在存储中的偏移为 2(因为我们需要跳过第一个点,它有两个项目),尺寸是Size
类的一个实例,包含一个元素,因为张量是一维的。重要的是要注意,这与张量对象的shape
属性中包含的信息相同:
# In[23]: second_point.shape # Out[23]: torch.Size([2])
步幅是一个元组,指示当索引在每个维度上增加 1 时,必须跳过存储中的元素数量。例如,我们的points
张量的步幅是(2, 1)
:
# In[24]: points.stride() # Out[24]: (2, 1)
在 2D 张量中访问元素i, j
会导致访问存储中的storage_offset + stride[0] * i + stride[1] * j
元素。偏移通常为零;如果这个张量是一个查看存储的视图,该存储是为容纳更大的张量而创建的,则偏移可能是一个正值。
Tensor
和Storage
之间的这种间接关系使得一些操作变得廉价,比如转置张量或提取子张量,因为它们不会导致内存重新分配。相反,它们包括为尺寸、存储偏移或步幅分配一个具有不同值的新Tensor
对象。
当我们索引特定点并看到存储偏移增加时,我们已经提取了一个子张量。让我们看看尺寸和步幅会发生什么变化:
# In[25]: second_point = points[1] second_point.size() # Out[25]: torch.Size([2]) # In[26]: second_point.storage_offset() # Out[26]: 2 # In[27]: second_point.stride() # Out[27]: (1,)
底线是,子张量的维度少了一个,正如我们所期望的那样,同时仍然索引与原始points
张量相同的存储。这也意味着改变子张量将对原始张量产生副作用:
# In[28]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) second_point = points[1] second_point[0] = 10.0 points # Out[28]: tensor([[ 4., 1.], [10., 3.], [ 2., 1.]])
这可能并不总是理想的,所以我们最终可以将子张量克隆到一个新的张量中:
# In[29]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) second_point = points[1].clone() second_point[0] = 10.0 points # Out[29]: tensor([[4., 1.], [5., 3.], [2., 1.]])
3.8.2 在不复制的情况下转置
现在让我们尝试转置。让我们拿出我们的points
张量,其中行中有单独的点,列中有X和Y坐标,并将其转向,使单独的点在列中。我们借此机会介绍t
函数,这是二维张量的transpose
的简写替代品:
# In[30]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) points # Out[30]: tensor([[4., 1.], [5., 3.], [2., 1.]]) # In[31]: points_t = points.t() points_t # Out[31]: tensor([[4., 5., 2.], [1., 3., 1.]])
提示 为了帮助建立对张量机制的扎实理解,可能是一个好主意拿起一支铅笔和一张纸,像图 3.5 中的图一样在我们逐步执行本节代码时涂鸦图表。
我们可以轻松验证这两个张量共享相同的存储
# In[32]: id(points.storage()) == id(points_t.storage()) # Out[32]: True
它们只在形状和步幅上有所不同:
# In[33]: points.stride() # Out[33]: (2, 1) # In[34]: points_t.stride() # Out[34]: (1, 2)
这告诉我们,在points
中将第一个索引增加 1(例如,从points[0,0]
到points[1,0]
)将会跳过存储中的两个元素,而增加第二个索引(从points[0,0]
到points[0,1]
)将会跳过存储中的一个元素。换句话说,存储按行顺序顺序保存张量中的元素。
我们可以将points
转置为points_t
,如图 3.6 所示。我们改变了步幅中元素的顺序。之后,增加行(张量的第一个索引)将沿着存储跳过一个元素,就像我们在points
中沿着列移动一样。这就是转置的定义。不会分配新的内存:转置只是通过创建一个具有不同步幅顺序的新Tensor
实例来实现的。
图 3.6 张量的转置操作
3.8.3 高维度中的转置
在 PyTorch 中,转置不仅限于矩阵。我们可以通过指定应该发生转置(翻转形状和步幅)的两个维度来转置多维数组:
# In[35]: some_t = torch.ones(3, 4, 5) transpose_t = some_t.transpose(0, 2) some_t.shape # Out[35]: torch.Size([3, 4, 5]) # In[36]: transpose_t.shape # Out[36]: torch.Size([5, 4, 3]) # In[37]: some_t.stride() # Out[37]: (20, 5, 1) # In[38]: transpose_t.stride() # Out[38]: (1, 5, 20)
从存储中右起维度开始排列数值(即,对于二维张量,沿着行移动)的张量被定义为contiguous
。连续张量很方便,因为我们可以有效地按顺序访问它们,而不需要在存储中跳跃(改善数据局部性会提高性能,因为现代 CPU 的内存访问方式)。当然,这种优势取决于算法的访问方式。
3.8.4 连续张量
PyTorch 中的一些张量操作仅适用于连续张量,例如我们将在下一章中遇到的view
。在这种情况下,PyTorch 将抛出一个信息性异常,并要求我们显式调用contiguous
。值得注意的是,如果张量已经是连续的,则调用contiguous
不会做任何事情(也不会影响性能)。
在我们的例子中,points
是连续的,而其转置则不是:
# In[39]: points.is_contiguous() # Out[39]: True # In[40]: points_t.is_contiguous() # Out[40]: False
我们可以使用contiguous
方法从非连续张量中获得一个新的连续张量。张量的内容将保持不变,但步幅和存储将发生变化:
# In[41]: points = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]]) points_t = points.t() points_t # Out[41]: tensor([[4., 5., 2.], [1., 3., 1.]]) # In[42]: points_t.storage() # Out[42]: 4.0 1.0 5.0 3.0 2.0 1.0 [torch.FloatStorage of size 6] # In[43]: points_t.stride() # Out[43]: (1, 2) # In[44]: points_t_cont = points_t.contiguous() points_t_cont # Out[44]: tensor([[4., 5., 2.], [1., 3., 1.]]) # In[45]: points_t_cont.stride() # Out[45]: (3, 1) # In[46]: points_t_cont.storage() # Out[46]: 4.0 5.0 2.0 1.0 3.0 1.0 [torch.FloatStorage of size 6]
请注意,存储已经重新排列,以便元素按行排列在新存储中。步幅已更改以反映新布局。
作为复习,图 3.7 再次显示了我们的图表。希望现在我们已经仔细研究了张量是如何构建的,一切都会变得清晰。
图 3.7 张量的偏移、大小和步幅之间的关系。这里的张量是一个更大存储的视图,就像在创建更大的张量时可能分配的存储一样。
3.9 将张量移动到 GPU
到目前为止,在本章中,当我们谈论存储时,我们指的是 CPU 上的内存。PyTorch 张量也可以存储在不同类型的处理器上:图形处理单元(GPU)。每个 PyTorch 张量都可以传输到 GPU 中的一个(或多个)以执行高度并行、快速的计算。将在张量上执行的所有操作都将使用 PyTorch 提供的 GPU 特定例程执行。
PyTorch 对各种 GPU 的支持
截至 2019 年中期,主要的 PyTorch 发行版只在支持 CUDA 的 GPU 上有加速。PyTorch 可以在 AMD 的 ROCm 上运行(rocm.github.io
),主存储库提供支持,但到目前为止,您需要自行编译它。(在常规构建过程之前,您需要运行tools/amd_build/build_amd.py
来转换 GPU 代码。)对 Google 的张量处理单元(TPU)的支持正在进行中(github.com/pytorch/xla
),当前的概念验证可在 Google Colab 上公开访问:https://colab.research.google.com。在撰写本文时,不计划在其他 GPU 技术(如 OpenCL)上实现数据结构和内核。](https://colab.research.google.com)
3.9.1 管理张量的设备属性
除了dtype
,PyTorch 的Tensor
还有device
的概念,即张量数据所放置的计算机位置。以下是我们如何通过为构造函数指定相应参数来在 GPU 上创建张量的方法:
# In[64]: points_gpu = torch.tensor([[4.0, 1.0], [5.0, 3.0], [2.0, 1.0]], device='cuda')
我们可以使用to
方法将在 CPU 上创建的张量复制到 GPU 上:
# In[65]: points_gpu = points.to(device='cuda')
这样做会返回一个新的张量,其中包含相同的数值数据,但存储在 GPU 的 RAM 中,而不是常规系统 RAM 中。现在数据存储在 GPU 上,当对张量执行数学运算时,我们将开始看到之前提到的加速效果。在几乎所有情况下,基于 CPU 和 GPU 的张量都暴露相同的用户接口,这样编写代码就更容易,不用关心重要的数值计算到底在哪里运行。
如果我们的机器有多个 GPU,我们还可以通过传递一个从零开始的整数来决定将张量分配到哪个 GPU 上,例如
# In[66]: points_gpu = points.to(device='cuda:0')
此时,对张量执行的任何操作,例如将所有元素乘以一个常数,都是在 GPU 上执行的:
# In[67]: points = 2 * points # ❶ points_gpu = 2 * points.to(device='cuda') # ❷
❶ 在 CPU 上执行的乘法
❷ 在 GPU 上执行的乘法
请注意,points_gpu
张量在计算结果后并没有返回到 CPU。这是这一行中发生的事情:
points
张量被复制到 GPU 上。- 在 GPU 上分配一个新的张量,并用于存储乘法的结果。
- 返回一个指向该 GPU 张量的句柄。
因此,如果我们还向结果添加一个常数
# In[68]: points_gpu = points_gpu + 4
加法仍然在 GPU 上执行,没有信息流向 CPU(除非我们打印或访问生成的张量)。为了将张量移回 CPU,我们需要在to
方法中提供一个cpu
参数,例如
# In[69]: points_cpu = points_gpu.to(device='cpu')
我们还可以使用cpu
和cuda
的简写方法,而不是to
方法来实现相同的目标:
# In[70]: points_gpu = points.cuda() # ❶ points_gpu = points.cuda(0) points_cpu = points_gpu.cpu()
❶ 默认为 GPU 索引 0
还值得一提的是,通过使用to
方法,我们可以通过同时提供device
和dtype
作为参数来同时更改位置和数据类型。
3.10 NumPy 互操作性
我们在这里和那里提到了 NumPy。虽然我们不认为 NumPy 是阅读本书的先决条件,但我们强烈建议您熟悉 NumPy,因为它在 Python 数据科学生态系统中无处不在。PyTorch 张量可以与 NumPy 数组之间进行非常高效的转换。通过这样做,我们可以利用围绕 NumPy 数组类型构建起来的 Python 生态系统中的大量功能。这种与 NumPy 数组的零拷贝互操作性归功于存储系统与 Python 缓冲区协议的工作(docs.python.org/3/c-api/buffer.html
)。
要从我们的points
张量中获取一个 NumPy 数组,我们只需调用
# In[55]: points = torch.ones(3, 4) points_np = points.numpy() points_np # Out[55]: array([[1., 1., 1., 1.], [1., 1., 1., 1.], [1., 1., 1., 1.]], dtype=float32)
这将返回一个正确大小、形状和数值类型的 NumPy 多维数组。有趣的是,返回的数组与张量存储共享相同的底层缓冲区。这意味着numpy
方法可以在基本上不花费任何成本地执行,只要数据位于 CPU RAM 中。这也意味着修改 NumPy 数组将导致源张量的更改。如果张量分配在 GPU 上,PyTorch 将把张量内容复制到在 CPU 上分配的 NumPy 数组中。
相反,我们可以通过以下方式从 NumPy 数组获得一个 PyTorch 张量
# In[56]: points = torch.from_numpy(points_np)
这将使用我们刚刚描述的相同的缓冲区共享策略。
注意 PyTorch 中的默认数值类型是 32 位浮点数,而 NumPy 中是 64 位。正如在第 3.5.2 节中讨论的那样,我们通常希望使用 32 位浮点数,因此在转换后,我们需要确保具有dtype torch .float
的张量。
3.11 广义张量也是张量
对于本书的目的,以及一般大多数应用程序,张量都是多维数组,就像我们在本章中看到的那样。如果我们冒险窥探 PyTorch 的内部,会有一个转折:底层数据存储方式与我们在第 3.6 节讨论的张量 API 是分开的。只要满足该 API 的约定,任何实现都可以被视为张量!
PyTorch 将调用正确的计算函数,无论我们的张量是在 CPU 还是 GPU 上。这是通过调度机制实现的,该机制可以通过将用户界面 API 连接到正确的后端函数来满足其他张量类型的需求。确实,还有其他种类的张量:有些特定于某些类别的硬件设备(如 Google TPU),而其他的数据表示策略与我们迄今所见的稠密数组风格不同。例如,稀疏张量仅存储非零条目,以及索引信息。图 3.8 左侧的 PyTorch 调度程序被设计为可扩展的;图 3.8 右侧所示的用于适应各种数字类型的后续切换是实现的固定方面,编码到每个后端中。
图 3.8 PyTorch 中的调度程序是其关键基础设施之一。
我们将在第十五章遇到量化张量,它们作为另一种具有专门计算后端的张量类型实现。有时,我们使用的通常张量被称为稠密或分步,以区别于使用其他内存布局的张量。
与许多事物一样,随着 PyTorch 支持更广泛的硬件和应用程序范围,张量种类的数量也在增加。我们可以期待随着人们探索用 PyTorch 表达和执行计算的新方法,新的种类将继续出现。
3.12 序列化张量
在需要的时候,即使现场创建张量也很好,但如果其中的数据很有价值,我们会希望将其保存到文件中,并在某个时候加载回来。毕竟,我们不想每次运行程序时都从头开始重新训练模型!PyTorch 在底层使用pickle
来序列化张量对象,还有专门的存储序列化代码。这里是如何将我们的points
张量保存到一个 ourpoints.t 文件中的方法:
# In[57]: torch.save(points, '../data/p1ch3/ourpoints.t')
作为替代方案,我们可以传递文件描述符而不是文件名:
# In[58]: with open('../data/p1ch3/ourpoints.t','wb') as f: torch.save(points, f)
类似地,加载我们的点也是一行代码
# In[59]: points = torch.load('../data/p1ch3/ourpoints.t')
或者,等效地,
# In[60]: with open('../data/p1ch3/ourpoints.t','rb') as f: points = torch.load(f)
虽然我们可以快速以这种方式保存张量,如果我们只想用 PyTorch 加载它们,但文件格式本身不具有互操作性:我们无法使用除 PyTorch 之外的软件读取张量。根据使用情况,这可能是一个限制,也可能不是,但我们应该学会如何在需要时以互操作的方式保存张量。接下来我们将看看如何做到这一点。
3.12.1 使用 h5py 序列化到 HDF5
每种用例都是独特的,但我们怀疑在将 PyTorch 引入已经依赖不同库的现有系统时,需要以互操作方式保存张量将更常见。新项目可能不需要这样做那么频繁。
然而,在需要时,您可以使用 HDF5 格式和库(www.hdfgroup.org/solutions/hdf5)。HDF5 是一种便携式、广泛支持的格式,用于表示序列化的多维数组,以嵌套的键值字典组织。Python 通过h5py
库(www.h5py.org)支持 HDF5,该库接受并返回 NumPy 数组形式的数据。
我们可以使用以下命令安装h5py
$ conda install h5py
在这一点上,我们可以通过将其转换为 NumPy 数组(如前所述,没有成本)并将其传递给create_dataset
函数来保存我们的points
张量:
# In[61]: import h5py f = h5py.File('../data/p1ch3/ourpoints.hdf5', 'w') dset = f.create_dataset('coords', data=points.numpy()) f.close()
这里的'coords'
是 HDF5 文件中的一个键。我们可以有其他键–甚至是嵌套的键。在 HDF5 中的一个有趣之处是,我们可以在磁盘上索引数据集,并且只访问我们感兴趣的元素。假设我们只想加载数据集中的最后两个点:
# In[62]: f = h5py.File('../data/p1ch3/ourpoints.hdf5', 'r') dset = f['coords'] last_points = dset[-2:]
当打开文件或需要数据集时,数据不会被加载。相反,数据会保留在磁盘上,直到我们请求数据集中的第二行和最后一行。在那时,h5py
访问这两列并返回一个类似 NumPy 数组的对象,封装了数据集中的那个区域,行为类似 NumPy 数组,并具有相同的 API。
由于这个事实,我们可以将返回的对象传递给torch.from_numpy
函数,直接获得一个张量。请注意,在这种情况下,数据被复制到张量的存储中:
# In[63]: last_points = torch.from_numpy(dset[-2:]) f.close()
加载数据完成后,我们关闭文件。关闭 HDFS 文件会使数据集无效,尝试在此之后访问dset
将导致异常。只要我们按照这里显示的顺序进行操作,我们就可以正常工作并现在可以使用last_points
张量。
3.13 结论
现在我们已经涵盖了我们需要开始用浮点数表示一切的一切。我们将根据需要涵盖张量的其他方面–例如创建张量的视图;使用其他张量对张量进行索引;以及广播,简化了在不同大小或形状的张量之间执行逐元素操作的操作–。
在第四章中,我们将学习如何在 PyTorch 中表示现实世界的数据。我们将从简单的表格数据开始,然后转向更复杂的内容。在这个过程中,我们将更多地了解张量。
3.14 练习
- 从
list(range(9))
创建一个张量a
。预测并检查大小、偏移和步长。
- 使用
b = a.view(3, 3)
创建一个新的张量。view
函数的作用是什么?检查a
和b
是否共享相同的存储。 - 创建一个张量
c = b[1:,1:]
。预测并检查大小、偏移和步长。
- 选择一个数学运算,如余弦或平方根。你能在
torch
库中找到相应的函数吗?
- 对
a
逐元素应用函数。为什么会返回错误? - 使函数工作需要什么操作?
- 是否有一个在原地操作的函数版本?
3.15 总结
- 神经网络将浮点表示转换为其他浮点表示。起始和结束表示通常是人类可解释的,但中间表示则不太容易理解。
- 这些浮点表示存储在张量中。
- 张量是多维数组;它们是 PyTorch 中的基本数据结构。
- PyTorch 拥有一个全面的标准库,用于张量的创建、操作和数学运算。
- 张量可以序列化到磁盘并重新加载。
- PyTorch 中的所有张量操作都可以在 CPU 和 GPU 上执行,而不需要更改代码。
- PyTorch 使用尾随下划线来表示一个函数在张量上的原地操作(例如,
Tensor.sqrt_
)。
¹ 由于感知不是一个简单的规范,人们提出了许多权重。例如,参见en.wikipedia.org/wiki/Luma_(video)
。
²Tim Rocktäschel 的博文“Einsum is All You Need–Einstein Summation in Deep Learning”( rockt.github.io/2018/04/30/einsum
)提供了很好的概述。
³ 参见 Sasha Rush 的博文“Tensor Considered Harmful”,Harvardnlp,nlp.seas.harvard.edu/NamedTensor
。
⁴ 以及在uint8
的情况下的符号。
⁵ 在未来的 PyTorch 版本中,Storage
可能无法直接访问,但我们在这里展示的内容仍然提供了张量在内部工作方式的良好思维图。