跳到主要内容

张量 — 数据在框架里长什么样

这一章讲三件事: 为什么所有数据都要先塞进同一种容器; 这个容器的形状怎么读、怎么改;以及三个新手一定会踩、而且踩了不报错的坑。

它在全书链条里的位置: 这是第一块砖。后面二十章里的图片、句子、社交网络, 在框架眼里全都是同一种东西——只是形状不同。

需要的基础: 无。看得懂 [[1, 2], [3, 4]] 这种写法就能读。

1. 先看现象:同一份数据换个摆法,程序就跑不动了

你手上有 100 张 28×28 的灰度图。要喂给一个只接受「一排数」的模型, 得先把每张图拍扁成 784 个数;要喂给一个专门吃图片的模型,又得把它摆回 28×28, 前面还要再加一个「这是第几张」的层次。

同一份像素,三种摆法,而且哪一种都不能少。 所以框架做的第一件事,就是规定一种能表达任意摆法的统一容器。

书里给它的定义很直白:深度学习里的计算几乎都建立在一排排数的运算之上, 框架沿用这一思路,把数据统一组织为张量1

先把两个数学词交代掉,后面全书都要用。 一排数就叫向量,比如 [2.0, 3.0, 4.0]

一张由行和列组成的数表就叫矩阵,比如 [[1, 2, 3], [4, 5, 6]]

张量是把这两样往上推:嵌套一层是向量,两层是矩阵,三层就是一摞矩阵。

一维张量 shape [3] 二维张量 shape [2, 3] 三维张量 shape [2, 2, 5]
┌─────────────┐ ┌─────────────┐ ┌──────────────────┐
│ 2.0 3.0 4.0 │ │ 1.0 2.0 3.0 │ │ ┌──────────────┐ │
└─────────────┘ │ 4.0 5.0 6.0 │ │ │ 1 2 3 4 5│ │
└─────────────┘ │ │ 6 7 8 9 10│ │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ 11 12 13 14 15│ │
│ │ 16 17 18 19 20│ │
│ └──────────────┘ │
└──────────────────┘

图说:这三个容器装的东西完全不同,但在框架看来是同一类对象,
只是「嵌套了几层」和「每层各有几个」不一样。这两件事合起来就叫形状。

这一章的主走查从这里开始,一路走到第 6 节。

全章主走查:一个装着 1 到 30 的三阶容器
────────────────────────────────────────────────────────────
① 建出来 t = torch.tensor([[[1..5],[6..10]], [[11..15],[16..20]], [[21..25],[26..30]]])
打印形状 → torch.Size([3, 2, 5])
② 读它的属性 嵌套层数 3、第 0 层 3 个、最后一层 5 个、总共 30 个数
③ 换个形状说法 reshape 成 [2, 5, 3] —— 底下的 30 个数一个都没动
④ 取值 t[0] → 一张 2×5 的表
t[0,1] → 一排数 [6, 7, 8, 9, 10],形状 [5]
t[0,1,0] → 6,这才是一个数本身
⑤ 改值 t[0] = 0 —— 原地覆盖,旧值不留副本
⑥ 和别的形状相加 [2,3,1,5] + [3,4,1] → [2,3,4,5] (框架自己补齐)
────────────────────────────────────────────────────────────

形状和取值取自书里的实际输出;嵌套层数与总元素个数是按定义算出来的
——书里对这个容器只打印过形状,那两个属性是在另一个容器上演示的。

2. 「维度」这个词在这里换了意思

这一节先拆一个一定会读岔的词。

在线性代数里,「三维向量」指的是这个向量有三个分量:[1, 2, 3]。 在这里不是。这里的「维度」指的是嵌套了几层,书里专门用一条笔记提醒了这件事: 这里的「维度」指张量的「阶」,与线性代数中向量的「维数」含义不同2

所以 [1, 2, 3] 在框架里是「一维张量」,尽管它有三个分量。 说得再明白些:

说法指的是什么例子
阶 / 维度 / ndim嵌套了几层[[1,2],[3,4]] 的阶是 2
形状 / shape每一层各有几个上面那个的形状是 [2, 2]
轴 / axis第几层第 0 轴长 2、第 1 轴长 2
维数(线性代数里的)一个向量有几个分量[1,2,3] 是三维向量,但在框架里是一阶张量

书里给的例子是一个形状为 [2, 2, 5] 的三阶张量: 每个数字表示对应轴上的元素个数——第 0 轴 2 个、第 1 轴 2 个、第 2 轴 5 个3

主走查第 ① 步: 把 1 到 30 按「3 摞、每摞 2 行、每行 5 个」摆好, 打印形状,得到 torch.Size([3, 2, 5])4

3. 四个属性,回答四个不同的问题

结论先行:形状不是一个数,是一组数;所以要有四种问法。

框架把它们摆成四个属性5:

  • ndim —— 嵌套了几层。一个孤零零的数是 0,一排数是 1,一张表是 2。

  • shape —— 各层长度组成的一串数,也就是上一节那个形状。

  • shape[n] —— 单独取第 n 层的长度;写成 shape[-1] 就是最后一层。

  • numel() —— 总共有多少个数,等于形状里各项相乘。

书里用一个形状 [2, 3, 4, 5] 的四阶张量把这四个属性一次打印了出来: 嵌套层数 4、形状 torch.Size([2, 3, 4, 5])、第 0 层 2 个、最后一层 5 个、 总共 120 个数6120 正是 2 × 3 × 4 × 5。

主走查第 ② 步(照定义算,不是书里打印的): 我们那个容器嵌套了 3 层、 第 0 层 3 个、最后一层 5 个、总共 3 × 2 × 5 = 30 个数。

再补一件事:同一个容器里所有的数必须是同一种类型。 书里把这条写成硬约束:同一张量里不允许混用元素类型7。理由是性能—— 类型固定,底层才能按固定的步长去缓冲区里寻址;混着放就得逐个判断,一次算一大片的前提就没了。

这个类型叫数据类型(dtype)。 有两条默认规则要记住,不然会莫名其妙8: Python 的整数字面量会被推断成 64 位整数,浮点字面量会被推断成 32 位浮点数 ——注意不是 64 位,这是深度学习的惯例,因为训练本来就不需要那么高的精度 (能表示到小数点后多少位)。

4. 取一个下标和取两个下标,拿到的不是同一种东西

这一节是全章最容易咬人的地方,主走查的第 ③ 到第 ⑤ 步都在这里。

先说改形状。 有一个常用动作叫 reshape:在不动任何一个数的前提下重组层次。 书里把我们那个 [3, 2, 5] 的容器重塑成 [2, 5, 3],然后明说: 这只是换了一组形状描述,底层缓冲区里的元素数值与排列顺序都没有动过9

主走查第 ③ 步: [3,2,5][2,5,3],30 个数原封不动,只是「怎么分组」变了。 还有个小技巧:某一层写成 -1,框架会按「总数除以其余层的乘积」自动反推; 所以 reshape(-1) 直接把它拍成一排 30 个数10

再说取值。 规则和 Python 列表一致:下标从 0 起,负数从尾端倒数, 切片写成 start:stop:step 且右端取不到11。多层时用逗号分隔,依次对应第 0 层、第 1 层…… 省略掉的尾部层次,等价于在那里写一个全选12

「等价于全选」这半句就是坑的来源。 主走查第 ④ 步:

t 装的是 1 到 30,形状 [3, 2, 5]

t[0] → 第 0 摞整张表 [[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10]] 形状 [2, 5]

t[0, 1] → 第 0 摞的第 1 行 [6, 7, 8, 9, 10] 形状 [5]
↑ 少写了一层下标,拿到的是一排数,不是一个数

t[0, 1, 0] → 第 0 摞第 1 行第 0 个 6 形状 []
↑ 三层全写满,这才是那个数本身

一个下标少写一层,结果就从一个数变成了一排数——而这一排数照样能参与运算、 照样能打印,程序不会报错,只会算出你不想要的答案。这是这一章要你带走的第一件事。

书里另有一个二维的例子容易和它混:一张 3×4 的表 [[0,1,2,3],[4,5,6,7],[8,9,10,11]], 取 [0, 1] 得到的是 113那里之所以拿到一个数,是因为那张表只有两层,两个下标正好写满。

最后说改值,主走查第 ⑤ 步。 下标和切片同样能放在等号左边,按相同的位置规则覆盖元素: t[0] = 0 会把第 0 摞整个抹成 014

这里有两个坑,书里都用提醒框标了出来:

坑一:这类赋值是原地写。 旧值被直接覆盖、不保留副本; 如果这个容器正参与自动求导,倒着算的时候只看得到覆盖之后的数,梯度就会偏离预期。 要留旧值,先 clone 再改15

坑二:从数组转过来的容器和原数组共用同一块内存。 torch.from_numpy() 得到的张量与原来的数组共享底层内存,改一个另一个跟着变; 想要一份独立的副本,得用 torch.tensor(arr)16

这两个坑长得不一样,病根是同一个:你以为你在造新东西,其实你在改旧东西。 框架还有一条命名约定专门交代这件事:函数名以下划线结尾的是原地版本 ——x.add(y) 返回新张量,而 x.add_(y) 会改写 x 本身17

5. 形状对不齐时,框架自己会补

结论先行:两个形状不同的容器也能相加,规则一共两步,而且很好记。

这套自动补齐的机制叫广播。书里给的触发条件是18: 从最后一层起往前逐层比较,每一层要么相等、要么其中一个是 1、要么其中一个不存在。

结果形状按两步推出来19:

  1. 层数不同,就在短的那个前面补 1,直到两者层数一样;
  2. 对齐后逐层比较,结果的每一层取两者中较大的那个。

主走查第 ⑥ 步,用书里那个例子:

x 的形状 [2, 3, 1, 5]
y 的形状 [3, 4, 1]
─────────────────────────────────
第一步:给 y 前面补 1 [1, 3, 4, 1]
第二步:逐层取大
max(2, 1) = 2
max(3, 3) = 3
max(1, 4) = 4
max(5, 1) = 5
─────────────────────────────────
结果形状 [2, 3, 4, 5]

反例同样重要: 形状 [2, 3, 4][2, 3, 6] 相加会失败, 因为最后一层上 4 与 6 既不相等、也都不是 120

矩阵乘法的广播规则要单独记一遍。 它只对最后两层做真正的矩阵乘, 其余层被当作「一次算多少份」那一维一起广播21;这一维就叫批。书里给的例子是:

x 形状 [10, 1, 5, 2] y 形状 [3, 2, 5]
────────────────────────────────────────────
最后两层: 5×2 乘 2×5 → 5×5
其余层: [10, 1] 与 [3] 按广播规则对齐 → [10, 3]
────────────────────────────────────────────
输出形状 [10, 3, 5, 5]

这个结果值得盯一眼:输入里没有任何一个形状长成 [10, 3, 5, 5], 它是两条规则叠出来的。 后面讲注意力切成好几份的时候会再用到同一个套路 ——把「切成了几份」塞进批那一维,让好几组计算一次做完。

6. 它放在哪块芯片上,是容器自己的一个属性

这一节讲一件工程上绕不开、但常被跳过的事。

同一个容器可以躺在处理器的内存里,也可以躺在显卡的内存里。 书里说这本书只涉及两类取值:cpucuda(显卡在框架里的写法); 不指定时默认建在处理器上22

关键的一条:位置不一致的两个容器不能直接做运算。 所以真跑起来的时候,模型和数据必须一起搬过去,少搬一个就报错。

为什么要有显卡这一路? 因为上一节那些运算天然可以拆开同时做—— 一万个数各自加一,彼此之间没有先后关系。这种「同时做很多份互不依赖的活」叫并行, 而显卡的结构正是为它设计的。 这也是为什么这一行的成本账最后都落到显卡上。

7. 作者的判断与证据

书里给了证据的:

  • 同一容器里类型必须统一——理由写明了是为了让底层按固定步长寻址,是性能约束不是洁癖7;
  • reshape 不动数据——书里把重塑前后的两次打印并排贴出,元素顺序肉眼可核9;
  • 广播的两条规则——书里对同一组形状先手推、再用代码验证,两边对得上19

属于作者提醒、书里没给实测证据的:

  • 原地写会让梯度偏离预期15——书里只说了「可能」,没有给一个具体跑歪的例子;
  • 原地操作节省内存但可能破坏计算图17——同样是提醒,没有配实验。

判断(我们的,不是书里的):这两条提醒的重要性被它们的排版位置低估了。 它们都被塞在小小的提醒框里,而它们造成的故障有一个共同点——不报错。 形状写错了程序会崩,崩了你就知道去改;而原地写和共用内存只会让结果悄悄不对。 如果错,会错在: 现代框架对「原地写破坏计算图」这件事其实会主动报错 (报出某个张量被就地修改过),所以这个坑在自动求导路径上没那么隐蔽; 真正无声的是共用内存那一条。判据是:去跑一遍原地写加反向,看框架报不报错。

8. 边界与局限

这一章没覆盖的(书里明显没有):

没讲什么为什么值得知道
只存非零元素的那种存法书里第 9 章讲网状数据时会用到,但张量这一章一个字没提
用低位宽的数来省显卡内存训练大模型时是必备手法,这本书全程用 32 位浮点数
步长与内存连续性reshape 有时会悄悄复制一份数据,书里只说「不动数值」,没说什么时候会复制
复数类型书里在列举可选类型时提了一句,之后再没出现

出门会撞见的名字(这一节的作用是:你在别人的代码和报错里见到它们,能认出就是这里讲的东西):

  • 形状不匹配的报错长这样:The size of tensor a (4) must match the size of tensor b (6) at non-singleton dimension 2——括号里的 2 指的是第 2 层,对照上一节的反例即可20;
  • unsqueeze 是在指定位置插一个长度为 1 的层,专门用来凑广播条件23;
  • squeeze 是它的反动作,把长度为 1 的层去掉;
  • transpose / permute 是交换层的顺序,二维时的简写是 .T24

9. 可带走的

  1. 框架里所有数据都是同一种容器,区别只在「嵌套几层」和「每层几个」;
  2. 这里的「维度」指嵌套层数,不是线性代数里向量的分量个数;
  3. reshape 只换形状说法,一个数都不动;某一层写 -1 让框架自己推;
  4. 少写一层下标,拿到的是一排数而不是一个数——不报错,只是答案不对;
  5. 原地写会覆盖旧值,要留旧值先 clone;
  6. 从数组转过来的张量和原数组共用同一块内存,要独立副本用 torch.tensor(arr);
  7. 函数名以下划线结尾 = 原地版本,这是一条全框架统一的命名约定;
  8. 广播两步走:短的前面补 1,再逐层取大;矩阵乘法只对最后两层生效,其余当批维;
  9. 容器放在哪块芯片上是它自己的属性,位置不一致不能直接算;
  10. 同一容器里的数必须同一类型,这是一次算一大片能快起来的前提。

10. 原文地图

主题原书章原文位置
张量的定义与阶第1章 实践基础text/02-ch01.txt:108(搜「把数据统一组织为张量」) · text/02-ch01.txt:114(搜「与线性代数中向量的」)
形状与轴第1章 实践基础text/02-ch01.txt:118(搜「张量的大小用形状」)
类型必须统一第1章 实践基础text/02-ch01.txt:133(搜「同一张量里不允许混用元素类型」)
四个属性第1章 实践基础text/02-ch01.txt:232(搜「维度个数」) · text/02-ch01.txt:270(搜「Number of dimensions」)
reshape 不动数据第1章 实践基础text/02-ch01.txt:294(搜「the shape of ndim」) · text/02-ch01.txt:306(搜「底层缓冲区里的」)
索引与切片第1章 实践基础text/02-ch01.txt:415(搜「访问和修改张量元素都依赖索引」) · text/02-ch01.txt:471(搜「First row and second column」)
原地写与共用内存第1章 实践基础text/02-ch01.txt:478(搜「这类赋值是原地写操作」) · text/02-ch01.txt:408(搜「共享底层内存」)
广播第1章 实践基础text/02-ch01.txt:608(搜「沿用 Numpy 的广播规则」) · text/02-ch01.txt:650(搜「举例」)
矩阵乘法的批维第1章 实践基础text/02-ch01.txt:660(搜「最后两维做矩阵乘」) · text/02-ch01.txt:669(搜「After matmul」)
设备位置第1章 实践基础text/02-ch01.txt:372(搜「创建张量时可以传」)

Footnotes

  1. 出处:「第1章 实践基础」第 108 段(text/02-ch01.txt:108,搜「把数据统一组织为张量」)。原文接着说:张量可以看作矩阵向高阶的推广,1 阶对应向量、2 阶对应矩阵,更高阶就是嵌套层数更多的多维数组。

  2. 出处:「第1章 实践基础」第 114 段(text/02-ch01.txt:114,搜「与线性代数中向量的」)。这是书里的一条笔记,原话提醒这里的「维度」指张量的「阶」,与线性代数中向量的「维数」含义不同,注意区分。

  3. 出处:「第1章 实践基础」第 118 段(text/02-ch01.txt:118,搜「张量的大小用形状」)。原文举的例子是一个三阶张量形状为 [2, 2, 5],每个数字表示对应轴上的元素个数。

  4. 出处:「第1章 实践基础」第 294 段(text/02-ch01.txt:294,搜「the shape of ndim」)。这是书里对这个装着 1 到 30 的三阶张量打印形状的实际输出:the shape of ndim_3_Tensor: torch.Size([3, 2, 5])书里对这个容器只打印过形状,没有打印它的阶数和元素总数。

  5. 出处:「第1章 实践基础」第 232 段(text/02-ch01.txt:232,搜「维度个数」)。原文列了四个属性:Tensor.ndimTensor.shapeTensor.shape[n]Tensor.numel()

  6. 出处:「第1章 实践基础」第 270 段(text/02-ch01.txt:270,搜「Number of dimensions」)。这是书里在一个形状为 [2, 3, 4, 5] 的四阶张量上打印四个属性的实际输出,元素总数为 120。注意这是另一个张量,不是主走查里那个 [3, 2, 5]。

  7. 出处:「第1章 实践基础」第 133 段(text/02-ch01.txt:133,搜「同一张量里不允许混用元素类型」)。原文给的理由是:这一约束让底层算子可以按固定步长寻址张量缓冲区,是高性能批量计算的前提。 2

  8. 出处:「第1章 实践基础」第 348 段(text/02-ch01.txt:348,搜「有符号 64 位整数」)与第 349 段(text/02-ch01.txt:349,搜「默认精度」)。原文写明 Python 整数对应 int64、Python 浮点数对应 float32,后者与深度学习训练惯例一致。

  9. 出处:「第1章 实践基础」第 306 段(text/02-ch01.txt:306,搜「底层缓冲区里的」)。原话:把 [3, 2, 5] 重塑为 [2, 5, 3] 只是换了一组形状描述,底层缓冲区里的元素数值与排列顺序都没有动过。 2

  10. 出处:「第1章 实践基础」第 312 段(text/02-ch01.txt:312,搜「自动反推该」)与第 326 段(text/02-ch01.txt:326,搜「得到长度为 30 的一维向量」)。书里同时提醒:一次 reshape 里最多只能出现一个 −1。

  11. 出处:「第1章 实践基础」第 415 段(text/02-ch01.txt:415,搜「访问和修改张量元素都依赖索引」)。原文给了两条规则:下标从 0 计起、负值从尾端倒数;切片写作 start:stop:step,半开区间。

  12. 出处:「第1章 实践基础」第 443 段(text/02-ch01.txt:443,搜「维数高于一维时」)。原话:省略掉的尾部维度等价于写一个全选切片。

  13. 出处:「第1章 实践基础」第 471 段(text/02-ch01.txt:471,搜「First row and second column」)。这是书里在一个 3×4 二维张量上取 [0, 1] 的实际输出 tensor(1)这个例子和主走查那个三阶张量不是同一个对象,拿它去推三阶张量的取值会得到错的结论。

  14. 出处:「第1章 实践基础」第 474 段(text/02-ch01.txt:474,搜「也能直接出现在赋值号左侧」)。原文演示了三种改法:按行改、按切片改、用省略号整体改。

  15. 出处:「第1章 实践基础」第 478 段(text/02-ch01.txt:478,搜「这类赋值是原地写操作」)。原话:旧值被直接覆盖且不会保留副本;如果当前张量正参与自动微分,反向传播只能看到覆盖之后的数值,可能让梯度计算偏离预期;需要保留旧值时应先 clone 再修改。 2

  16. 出处:「第1章 实践基础」第 408 段(text/02-ch01.txt:408,搜「共享底层内存」)。原话:torch.from_numpy() 得到的张量与原 Numpy 数组共享底层内存,对其中一个的修改会影响另一个;如果想要一个独立的副本,可以使用 torch.tensor(arr)

  17. 出处:「第1章 实践基础」第 686 段(text/02-ch01.txt:686,搜「函数名以下划线」)。原话:约定上,函数名以下划线结尾的版本即原位操作;原位操作虽节省内存,但可能破坏计算图,使用时需谨慎。 2

  18. 出处:「第1章 实践基础」第 608 段(text/02-ch01.txt:608,搜「沿用 Numpy 的广播规则」)。原文给的两个条件:两个张量都至少是一维;从最后一维起对齐逐维比较,每一维的大小要么相等、要么其中一个为 1、要么其中一个不存在。

  19. 出处:「第1章 实践基础」第 650 段(text/02-ch01.txt:650,搜「举例」)。原文用 [2, 3, 1, 5] 与 [3, 4, 1] 这一组手推了两步规则,结果形状为 [2, 3, 4, 5];前面第 620–633 行的代码把同一组形状跑了一遍,输出与手推一致。 2

  20. 出处:「第1章 实践基础」第 645 段(text/02-ch01.txt:645,搜「既不相等也都不是」)。报错原文见第 642 段(text/02-ch01.txt:642,搜「must match the size of tensor」)。 2

  21. 出处:「第1章 实践基础」第 660 段(text/02-ch01.txt:660,搜「最后两维做矩阵乘」)。实际输出见第 669 段(text/02-ch01.txt:669,搜「After matmul」):torch.Size([10, 3, 5, 5])

  22. 出处:「第1章 实践基础」第 372 段(text/02-ch01.txt:372,搜「创建张量时可以传」)。原文写明本书涉及的取值只有 cpu 与 cuda 两类,未指定时默认创建在 CPU 上,且设备位置不一致的张量之间不能直接做运算。

  23. 出处:「第1章 实践基础」第 330 段(text/02-ch01.txt:330,搜「增加一个长度为 1 的轴」)。原文说这类操作常用于补齐广播维度。

  24. 出处:「第1章 实践基础」第 593 段(text/02-ch01.txt:593,搜「张量类还提供常用的矩阵运算」)。原文列出的清单里包括转置、范数与矩阵乘法,并注明 .T 仅对二维张量、高阶用 transpose / permute。