张量

社区文章 发布于2025年6月7日

导言

张量已经存在了几个世纪,并且是当前机器学习革命的核心。它们目前存在于所有最先进的模型中。然而,尽管它们是基本构建块,但由于被 PyTorch、Candle、JAX 等 API 和框架抽象化,它们常常被误解。在这篇文章中,我希望深入理解张量,并为您提供一个坚实的范式来从数学和计算角度理解张量。我预计机器学习领域的大多数人对张量和线性代数都有一定的经验,因此我们将从熟悉的概念开始,逐步构建更强大的思想,这应该会给您带来新的见解。

张量

基础:从一个方程到多个方程

线性代数课程通常以描述一个线性方程开始,看起来像

ax + by + cz 的值是多少?

如果我们知道 a, b, cx, y, z,这很容易计算。比如说 a=1, b=2, c=3, x=4, y=5, z=6,那么

1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32

对于一个方程来说,这种表示法完全没问题,但当我们处理许多方程时,它就变得有点混乱了

a₁x + b₁y + c₁z
a₂x + b₂y + c₂z
a₃x + b₃y + c₃z
a₄x + b₄y + c₄z

在这种情况下,我们总共需要15个变量,所以人们想出了方便描述这些系统的符号。

向量:我们的第一个张量(一维)

回到我们只有一个方程的情况,我们把 a, b, c 放入一个我们称之为“数字列表”的结构:W = [a, b, c]。然后我们把 x, y, z 放入一个类似的结构 X = [x, y, z]。这些结构被称为向量,但更普遍地,它们被称为一维张量。

现在我们可以描述如何将它们相乘

W*X = [a*x, b*y, c*z]

image/gif

这已经完成了大部分工作,但我们期望的输出是一个单一的数字,而不是一个数字列表。如果我们能将这个列表中的所有项进行 SUM(求和),我们就能得到答案。我暂时用 SUM 作为简写,稍后会向您展示正确的张量操作。

SUM(W*X) = SUM([a*x, b*y, c*z]) = a*x + b*y + c*z

image/gif

所有张量都有一个形状。形状是一个数字列表,描述了维度数量以及每个维度中的元素数量。在我们的1D张量 W 的例子中,它的形状是 [3],因为我们有1个维度,其中有3个数字。

矩阵:扩展到二维张量

回到我们的多方程情况,我们可以把变量 W 变成一个“数字列表的列表”结构,称为矩阵,更普遍地,它是一个二维张量。

W = [
[a₁, b₁, c₁], 
[a₂, b₂, c₂], 
[a₃, b₃, c₃], 
[a₄, b₄, c₄], 
]

如果您想知道我为什么这样描述乘法,我很快会解释。由于向量是“数字列表”结构,我们可以将矩阵称为向量的列表。矩阵是一个二维对象,因此它的形状将包含2个数字。在我们的新张量 W 的例子中,它的形状是 [4, 3],表示第一个维度(行)有4个元素,第二个维度(列)有3个数字。

与上次类似,我们可以进行乘法。

W*X = [
[a₁*x, b₁*y, c₁*z],
[a₂*x, b₂*y, c₂*z],
[a₃*x, b₃*y, c₃*z],
[a₄*x, b₄*y, c₄*z],
]

image/gif

目前我们可以说 SUM 只应用于最内层维度(列)。

SUM(W*X) = [
a₁*x + b₁*y + c₁*z,
a₂*x + b₂*y + c₂*z
a₃*x + b₃*y + c₃*z
a₄*x + b₄*y + c₄*z
]

image/gif

索引:向内查看

很快,在继续之前,还有最后一件事,让我们放弃变量名。取而代之的是,我们将直接对张量进行索引以描述这些方程。

在上面的例子中,由于我们知道矩阵的形状,我们可以想出一种无需命名就能查找任何数字的方法。我们使用坐标表示法,也更广泛地称为下标表示法。

注:本文将全程使用1-based索引(W[1,1]表示第一个元素),而非编程中常见的0-based索引。这在从开头计数(1, 2, 3)和从结尾计数(-1, -2, -3)时,能保持良好的对称性。

所以现在我们的W矩阵中的项目是

W = [
[W[1,1], W[1,2], W[1,3]],
[W[2,1], W[2,2], W[2,3]],
[W[3,1], W[3,2], W[3,3]],
[W[4,1], W[4,2], W[4,3]],
]

您可以看到每个坐标都是唯一的,并且每个坐标最左边的索引描述了它在最顶层列表中的位置,随后的每个索引描述了它在下一层嵌套列表中的位置。

image/gif

使用相同的符号引入上面的 X,我们可以将我们的基本方程写成这样:

W[1,1]*X[1] + W[1,2]*X[2] + W[1,3]*X[3] 
W[2,1]*X[1] + W[2,2]*X[2] + W[2,3]*X[3] 
W[3,1]*X[1] + W[3,2]*X[2] + W[3,3]*X[3] 
W[4,1]*X[1] + W[4,2]*X[2] + W[4,3]*X[3]

附注:关于传统线性代数的说明

如果你对线性代数有哪怕一点点了解,你可能会对为什么我使用不同的符号来描述这个过程感到困惑。

W*X^T = [W[1]*X[1] + W[2]*X[2] + W[3]*X[3]]

传统线性代数会将其写成 W*X^T,其中转置 (^T) 人为地将我们的一维向量转换为二维矩阵,仅仅是为了使乘法运算成立。

这对矩阵和基础线性代数来说是一个非常有用的符号,因为这是迄今为止最常见的操作,并且具有很好的几何直观性。那么,我们为什么要抛弃过去200年左右都足够用的符号呢?问题在于,在这种表示法中,矩阵是第一类公民,一切都被视为二维张量。

例如,如果我们有一个向量,我们可以通过转置将其“推入”更高维度。转置对于矩阵是明确定义的,我们只需将其沿着从左上到右下的对角线“翻转”。但这本质上是一个二维操作。从几何角度思考更合理,你可以想象一个正方形沿着对角线翻转,但是一条线没有对角线可以翻转。线性代数迫使我们假装它有,随意地将水平排列变成垂直排列。对于一个包含 n 个元素的向量,它会将其变成一个尺寸为 1×n 的矩阵,本质上是从无到有地创建了一个维度。我们稍后会提到这一点,但如果可能的话,我们希望忽略这些无关的操作。

至于几何直观,传统的解释描述了给定基中的向量如何在规范基中表示。它依赖于将向量投影到形成新基的其他向量集合上的思想。我认为从计算实际发生的情况来思考更直观:每个基向量都按其输入向量的对应分量进行缩放,然后这些缩放后的向量首尾相加。这个过程本质上是找到指向规范基中特定晶格点的向量。你可以把它想象成找到通往第一个网格交点的对角线路径。

image/gif

形状:动态与固定

对于向量,我们的 W 向量的形状是 [3],但我们可以想象需要一个更长的向量的情况。

例如:

W[1]*X[1] + W[2]*X[2] + W[3]*X[3] + W[4]*X[4] + W[5]*X[5] + W[6]*X[6]

在这种情况下,我们的 W 向量将具有不同的形状,[6],但它仍然是一个一维张量。我们不逐一列出每种可能长度的具体示例,而是可以在形状中插入一个变量来表示尚未确定的维度大小。我们将此变量称为 n。因此 W 的形状现在是 [n],这应该也与我们的 X 向量具有相同的形状。

n 等于 3 时,我们回到最初的情况

W[1]*X[1] + W[2]*X[2] + W[3]*X[3]

n 等于 6 时,我们有新的方程

W[1]*X[1] + W[2]*X[2] + W[3]*X[3] + W[4]*X[4] + W[5]*X[5] + W[6]*X[6]

对于任意 n

W[1]*X[1] + W[2]*X[2] + W[3]*X[3] + … + W[n]*X[n]

这也适用于我们的矩阵。我们可以将 W 的形状描述为 [m,n],将 X 的形状描述为 [n]

对于任意 m, n

W[1,1]*X[1] + W[1,2]*X[2] + … + W[1,n]*X[n]
W[2,1]*X[1] + W[2,2]*X[2] + … + W[2,n]*X[n]
…
W[m,1]*X[1] + W[m,2]*X[2] + … + W[m,n]*X[n]

模式:构建更高维度

在我们将其扩展到三维之前,让我们后退一步。什么是零维张量?一个没有维度的张量?因此,如果一个矩阵是“数字列表的列表”,一个向量是“数字列表”,那么一个零维对象必须是一个数字,也称为标量。

现在让我们升级到三维。如果我们把向量看作线,矩阵看作正方形,那么三维张量就可以看作是一个立方体。有时很难理解三维张量看起来像什么,但扩展我们的模式:

0D - 标量 = 数字

1D - 向量 = “数字列表” = 标量列表

2D - 矩阵 = “数字列表的列表” = 向量列表

3D - “数字列表的列表的列表” = 矩阵列表

image/gif

因此,将三维张量视为矩阵列表,让我给您一个具体的例子:我们可以将每个矩阵视为一个灰度二维图像。我们可以将三个二维灰度图像堆叠成一个三色图像,其中每个图像代表不同的颜色。这就是人们在彩色胶片出现之前拍照的一种方式,他们用红色、绿色和蓝色滤镜拍摄三张照片,然后将它们组合起来。

我希望您能想象这如何扩展到更高维度的张量。

单例维度:一个有趣的副作用

单例维度是指大小为一的维度。虽然简单,但它们对于张量操作而言却出奇地强大。您可以将其概念化为“将低维张量包裹在另一个张量中”。

让我们从一个标量开始

5.0 的形状是 []

然后我们添加一个单例维度并将其转换为向量

[5.0] 的形状是 [1]

我们再添加一个单例维度,并将其变为矩阵

[[5.0]] 的形状是 [1, 1]

对于高维张量

[[[5.0]]] 的形状是 [1, 1, 1]

[[[[5.0]]]] 的形状是 [1, 1, 1, 1]

[[[[[5.0]]]]] 的形状是 [1, 1, 1, 1, 1]

image/gif

在张量中的任何位置添加单例维度都不会改变数据本身,它只会改变我们对张量的索引方式。

例如,虽然标量 5.0 就是它本身,但向量 [5.0] 需要索引到它的第一个条目才能取回标量,对于 [[5.0]],我们需要在两个维度中都索引到它的第一个条目才能取回标量。我们很快就会深入探讨这些重新索引操作的更复杂版本。

张量操作

Einop 符号:形状的语言

接下来我们将大量使用einop符号,因为它比传统方法使张量操作更具可读性。我将使用恒等操作(不执行任何操作的操作)来介绍它。

如果我们有一个形状为 [n] 的向量,我们可以这样写出 einop 符号中的恒等操作:

n -> n

箭头表示从输入形状到输出形状的转换。在这里,“n”表示任意大小,因此这适用于任何长度的向量。这是一个接受一个一维张量并输出一个一维张量的函数,用函数符号表示如下:

f(x) = x

在 Python 中,它看起来像这样

def f(x: Tensor1D) -> Tensor1D:
    return x

这是最简单的函数。

零维恒等式具有某种优雅

-> 

这种符号剥离了一切,只留下转换的基本概念,这让人感到满意。

置换:最简单的张量操作

我们要讨论的第一个操作是改变张量维度的顺序。如果您在任何机器学习框架中工作过,您可能使用过这个操作,并且对其工作原理有直观的认识。但是为了达成共识,让我详细讨论这个操作,作为后续内容的起点。这是熟悉 einop 符号工作原理的完美方式。

对于一维向量,只有一个维度可供操作,因此当您遍历元素时,您总是只需增加该单个坐标。零维或一维向量的唯一置换是恒等。

例如,正如我们之前讨论的,对于大小为 n 的向量

0D: ->

1D: n -> n

现在,一旦我们开始使用矩阵和更高维度的张量,置换就变得稍微更有意义了。

当我们对张量(例如我们的矩阵 W)进行索引时,坐标存在“顺序”。例如,不一定有 W[i, j] = W[j, i]。如果我们希望矩阵按该顺序排列,我们可以对维度进行置换。对于矩阵,这被称为“转置”操作,通常用矩阵后的上标 T (^T) 表示,例如 f(W) = W^T

在einop符号中,我们可以这样写

m n -> n m

再次强调,einop 符号描述的是一个接受二维张量并输出二维张量的函数,这一点很重要。

一个矩阵只有两种置换:恒等和转置。但是当我们向上移动到形状为 [k, m, n] 的三维张量时,有6个函数

k m n -> k m n
k m n -> k n m
k m n -> m k n
k m n -> n k m
k m n -> m n k
k m n -> n m k

旁注:对于每个维度 d,实际上有 d 阶乘个置换函数,因此

0D -> 0! = 1

1D -> 1! = 1*0! = 1

2D -> 2! = 2*1! = 2

3D -> 3! = 3*2! = 6

4D -> 4! = 4*3! = 24

5D -> 5! = 5*4! = 120

但是计算排列的重要性不如理解它们对数据实际做了什么。让我们看看排列在幕后是如何工作的。

当思考矩阵的列表类比时,考虑置换维度时实际发生了什么可能会令人困惑,尤其是对于三维及更高维度。将其视为您并未改变底层张量,而只是改变了查看它的方式,本质上是转换了坐标,或者重新索引,这会很有帮助。

例如,你有一个形状为 [k m n] 的 3D 张量 Q,你可以这样索引它 Q[1, 2, 3] 或其他方式。然后假设你使用以下方式置换张量:

f: k m n -> m k n

image/gif

如果您索引 f(Q)[1, 2, 3],这等于什么?我们只需将形状视为变量,并反向填充索引,如下所示:

k m n -> m k n
m = 1
k = 2
n = 3

所以它会输出

T[2, 1, 3]

简而言之

T[k, m, n] = f(T)[m, k, n] or T[i, j, k] = f(T)[j, i, k] 

这种思考 Einops 的方式有时会非常有用。

归约:

在本文的开头,我们定义了 SUM 函数并赋予了它一些任意属性,例如,在二维情况下,我们说它只应用于最后一个维度。现在我们可以创建一个更精确的定义。

用于归约的 einop 符号在“轴上”执行一个函数,并从张量中删除该轴。

它这样写

f: m n -sum> m

这表示一个函数,它接受一个形状为 [m n] 的张量,并对第二个维度 (n) 求和。输出中不出现的维度就是我们正在归约的维度。

例如,这个函数应用于我们的 W 矩阵将是

f(W) = [
W[1,1] + W[1,2] + W[1,3],
W[2,1] + W[2,2] + W[2,3],
W[3,1] + W[3,2] + W[3,3],
W[4,1] + W[4,2] + W[4,3],
]

您可以看到 f(W) 的形状是 [m]

我们也可以将函数改为乘法

g: m n -product> m

其中:

g(W) = [
W[1,1] * W[1,2] * W[1,3],
W[2,1] * W[2,2] * W[2,3],
W[3,1] * W[3,2] * W[3,3],
W[4,1] * W[4,2] * W[4,3],
]

为了计算一致性,内部函数只需是结合律的,这意味着 op(op(a,b),c)op(a, op(b,c)) 的结果应该没有区别。这确保了归约无论底层计算如何分组操作,都能得到相同的结果。

Amplect:核心多张量操作

Amplect 是一种不常出现的操作。它是广播操作(broadcast-op)的更通用名称。基本上,我们接受任意数量的张量,并对相应的维度执行关联的逐元素操作。让我举个例子

我们有两个张量

A: [a b c]
B: [b]

那么我们可以写

a b c, b -add> a b c

这在概念上通过添加单例维度将 B 扩展到与 A 的形状匹配:B 变为 [1 b 1]。然后我们对 A 和扩展后的 B 进行逐元素相加。

这可能有点难以理解,所以我喜欢用“传统”的方式来思考它。

我们有一个形状为 [a b c] 的输出,对于这个输出的每个索引 i j k,我们有以下方程:

Output[i, j, k] = A[i, j, k] + B[j]

附注:为什么广播不直观

广播操作在每个机器学习框架中都已司空见惯,但它们并不直观。你从两个形状不同的张量开始,通过将它们“广播”到共同的大小,然后逐元素地进行操作来将它们组合在一起。广播到共同大小的方法是:

  1. 从两个张量的最后一个(最右边)维度开始
  2. 从最后一个维度开始的每个维度
    • 检查两个张量当前维度的大小是否相等
      • 如果相等,继续
    • 检查张量1当前维度的大小是否为1
      • 如果是,将张量1当前维度的大小重复到张量2当前维度的大小,然后继续
    • 检查张量2当前维度的大小是否为1
      • 如果是,将张量2当前维度的大小重复到张量1当前维度的大小,然后继续
    • 否则,这两个张量不可广播

现在,如果您想将形状为 [a b c] 的张量与形状为 [b] 的张量进行广播加法,这会产生问题,因为此算法会失败,因此您需要为第二个张量显式添加一个额外的单例维度,例如 [b 1]。现在算法就可以工作了。

您可能已经注意到,有了单例维度,理论上任何张量都可以广播到任何其他张量。

例如,假设您有一个形状为 [a b c] 的张量 A 和一个形状为 [d e f] 的张量 B,这两个张量在传统意义上被认为是“不可广播”的,但如果您向其中一个张量,例如 A,添加单例维度,使其形状变为 [a b c 1 1 1],那么这两个张量将广播到一个共同的形状 [a b c d e f],这被称为外积。

所以从技术上讲,任何两个张量总是可以互相广播的,我们应该更具体地说明我们在广播时想要什么,这就是 amplect 的闪光点。

image/gif

矩阵乘法:终于

既然我们已经到了这里,我们终于可以用我们的新操作从头开始构建矩阵乘法了。

对于形状为 [a b] 的矩阵 A 和形状为 [b c] 的矩阵 B,我们有

a b, b c -multiply> a b c -sum> a c

第一个箭头表示一个 Amplect 操作,它执行广播乘法;第二个箭头表示一个归约操作,它对 b 维度(中间 [a b c] 结果的中间维度)执行求和。

更普遍接受的写法是使用 einsum 符号,这是一种标准的张量操作数学符号。

a b, b c -> a c

这种 einsum 符号更简洁,并且已成为大多数框架中表达矩阵乘法的传统方式。

Einop 符号:优缺点

Einop 符号的优势在哪里?

可读性: 理解操作在做什么变得更容易,并且标准化了,你可以快速理解正在发生的事情,而无需在线查找或阅读论文。

简洁性: 它非常简单,因此易于教授。

表达力: 您可以描述那些没有标准名称,或者在传统线性代数中需要多个步骤的复杂操作。

如果您像我一样,您可能会想知道为什么这种符号没有更广泛地传播,考虑到它的简单性和表达力。这主要有3个原因:

硬件: 特定操作在特定硬件上运行速度快得多,为该硬件提供特定操作可以显著提高性能。

惯性: 当前系统已经足够好用,所以更具表达力的符号并不是必需的,神经网络架构变化不大。

卷积: 这种符号无法优雅地表达卷积。

最后:卷积与 Einops

我认为卷积是机器学习中最重要的操作之一,它非常擅长学习相邻和连续数据中的模式,例如图像或音频。问题在于,在这种表示法中,没有一种优雅的方式能够表达具有与 PyTorch 相同选项的卷积。我预计现在这可能无法完全理解,但原因在于,卷积(对于形状为 [colors, h, w]A 和形状为 [out_channels, colors, kh, kw]B)首先需要将 B 用零填充到与 A 相同的形状,然后

A_tilde = fft(A, axes=(-2, -1))
B_tilde = fft(B, axes=(-2, -1))
f: colors h w, out_channels colors h w -mul> out_channels colors h w -sum> out_channels h w
A_pre = f(A_tilde, B_tilde)
A_conv = ifft(A_pre, axes=(-2, -1))
A_conv: [out_channels h w]

这完全可行,但大多数人并没有将卷积概念化为频域中的乘法。填充也不直观,并且像核膨胀、步幅和边缘填充这样的实际问题也无法很好地转换为这种方法。

结语

希望现在您不再将张量视为 PyTorch API 背后神秘的抽象,而是将其视为组织和转换数据的结构化方式。您真正只需要一个二进制张量操作就能构建所有其他操作,这揭示了计算本身的一些深层含义。

张量不仅仅是数字的容器。它们是信息如何在我们的模型中流动的基本语言。当您看到变压器的注意力机制或 CNN 特征图时,您不再看到黑箱,而是看到您可以推理和改进的协调张量变换。

社区

很棒的文章!我感觉这篇文章被低估了😂

是的,被严重低估了!
确实是篇好文章👏

注册登录发表评论