数学张量与工程张量:指标、爱因斯坦求和与einsum实战
2026/9/18 9:41:21 网站建设 项目流程

1. 先把"张量"这个词从神坛上请下来

我刚开始接触张量的时候,被折磨得不轻。教材前面讲坐标变换、讲上下标、讲求和指标,一套符号体系铺开来,等到终于看到"张量就是多重线性映射"这句话,人已经晕了。后来做深度学习,发现情况反过来了:PyTorch 里的torch.Tensor被翻译成"张量",但此张量非彼张量。它只是"能放进显卡的 n 维数组"而已。两拨人用同一个词,说的却不是同一件事,这就是我见过的绝大多数张量入门卡点的根源。

所以这篇内容我想做的事情很明确:把数学意义上的张量和工程意义上的张量放在一起讲,讲清楚它们各自的语言规则,尤其是求和指标这一套东西到底在表达什么、为什么值得花时间学。适合谁看?如果你是物理、力学、材料方向的本科生或研究生,正在被张量分析的第一章劝退;或者你是做深度学习工程的,天天写einsumpermutebroadcast,但不太清楚这些操作背后的数学依据——那这篇就是为你写的。两种背景的读者我都不打算放弃,前面偏概念,中间偏符号,后面落到代码,你可以按需跳读。

我给这篇文章定的基调是:能算的必须能写成代码验证,能写的必须说清楚它为什么这么写。符号体系不是排版洁癖,它是为了让你在三十个指标翻滚的公式里不迷路。

1.1 从物理量和坐标变换理解张量的本质

很多人对张量的第一印象是"高维数组"。这个印象来自编程,但在数学上它是错的,至少是不完整的。真正定义张量的从来不是"有几个指标",而是它在坐标变换下如何变化

拿最常见的一阶张量——向量——举例。一个位移向量在二维平面上是 (dx, dy),你把坐标系旋转 θ 角,新的分量变成 (dx', dy'),两者之间用一个旋转矩阵联系。关键点在于:这个变换关系是由坐标系的变化唯一决定的,跟向量本身代表什么物理量无关。再看二阶张量,比如弹性力学里的应力张量 σ_ij,它描述的是"某个面上单位面积受力"这件事,你在数学上可以把它摆成 3×3 的数表,但真正让它成为张量的是这样一个事实:换一组基之后,新的 9 个分量可以通过两次坐标变换矩阵算出来。

这里我要强调一个我踩过的坑。当年我做了个练习,把一组 9 个数随意赋值,然后假装它是应力张量去做坐标变换,算完发现结果和实验对不上。原因很简单——那 9 个数是我编的,它不满足任何物理约束(比如对称性、平衡方程),自然也不是任何真实场的分量。这件事给我的教训是:张量是"带着变换规则的数组",而不是"一堆数字"。判断一个对象是不是张量,先问它的分量在坐标变换下怎么变,而不是先数它有几个下标。

一个可操作的判据是:如果某个量的分量在你换基之后,能够写成"旧分量 × 若干个变换矩阵的乘积再求和"这种标准形式,那它就是张量;如果多出来一项额外的平移项,那它通常不是(连接系数就是这个经典反例)。这个判据在实践中比任何抽象定义都管用,尤其是做连续介质、电磁场、广义相对论这类内容的时候。

1.2 阶、指标与分量:一套符号系统的三个层次

明白了"怎么变"之后,再来看"怎么记"。张量的语言里有三个层次的东西,初学时最容易混。

第一层是阶(rank / order),也就是需要的指标个数。0 阶是标量,1 阶是向量,2 阶是矩阵,3 阶以上就只能靠想象了。我在讲课时通常提醒:这里的"阶"是"指标个数",不是矩阵的"维数"。一个 2 阶张量可以是 3×3,也可以是 4×4,阶和维数是两个正交的概念。

第二层是指标的位置,也就是上标和下标。在欧氏空间的直角坐标系下,上标下标可以不分,因为度规是单位矩阵,升降指标什么都没改变。但一旦进入一般坐标,甚至只是换成球坐标、柱坐标,上下标就必须分开写。这是张量符号体系里最容易被新手忽略、但在实际计算中最容易出错的地方。

第三层是分量。张量本身是抽象的几何对象,写下 A_ij 的时候,你写的是它在某组基下的分量。这一点听起来像哲学,但实操上非常关键:当你做数值计算时,程序里存放的永远是分量数组,你必须在心里记住当前用的是哪组基、哪个坐标系。

提示:初学阶段如果分不清"张量"和"分量",可以先按工程习惯把两者当同一个东西用,但心里要留一个标注——等进入曲线坐标或者非正交基,这个缺口会立刻暴露出来。

我个人建议的学习顺序是:先用直角坐标系把指标运算练熟,手写到不假思索;再引入一般坐标,理解度规和升降指标;最后再看协变导数、曲率这些更深的东西。跳过第二步直接上第三步,基本是自讨苦吃。

1.3 机器学习语境下的"张量"其实降级了

说个可能不太政治正确但很实在的判断:PyTorch 里的Tensor,在数学意义上更接近"多维数组加自动求导节点",而不是严格定义的张量。它没有坐标变换的概念,也没有协变逆变之分。torch.randn(3, 4, 5)就是一堆数,你把它喂进网络,它不会问你"你是几阶张量、在哪个坐标系下"。

那为什么还要学数学张量?我的理由有三个,都很功利。

一是运算的直觉einsum('bij,bjk->bik', A, B)这行代码,本质上就是批量矩阵乘法,写成指标式就是 C^i{}{jk} = Σ_j A^i{}{jl} B^l{}_{k}。你看,代码里的字符串跟指标式几乎是一一对应的。学会了指标语言,写einsum就不再靠查文档,而是靠推导。

二是形状变换的底气permutetransposeview这些操作,本质上是改变指标的排列顺序或者合并指标。如果你只会背 API,遇到 5 维的特征图会疯;如果你习惯用指标去思考,会很清楚哪两个指标该合并、哪个该放在前面。

三是读懂论文。张量网络、张量分解(CP、Tucker)、量子多体、连续介质,这些方向的文献里指标满天飞。看不懂指标,等于看不懂半个领域。

所以我的定位是:数学张量给你骨架,工程张量给你肌肉。缺哪个都会跛。

2. 指标记号:上下标真不是排版洁癖

很多教材一上来就写 A^i_j,学生第一反应是"为什么要分上下",第二反应是"下标上标不就是排版吗"。我在各种场合解释过这个问题,最有效的类比是这样:上标是"方向"类指标,下标是"测量"类指标

向量本身(位移、速度)是方向类的东西,用上标;而梯度、动量这类"吃掉一个方向吐出个数"的东西,是测量类的,用下标。你算一个向量在某方向上的投影,就是把两者缩并掉。理解了这层直觉,后面看到升降指标、看到度规,就不会觉得它是凭空冒出来的规则。

2.1 逆变与协变:它们到底差在哪里

先给个可操作的定义:逆变分量(上标)随基向量反向变化,协变分量(下标)随基向量同向变化。这句话背下来不难,难的是理解它为什么必须如此。

考虑二维斜坐标系下的一个位移向量。你把基向量拉长一倍,要表示同一个物理位移,坐标数值就得砍一半——这就是"反向变化"。而梯度这种东西不一样:你把基向量拉长一倍,同样的坐标间隔对应的物理距离变大,梯度的分量反而变大——这就是"同向变化"。两类东西的变换规律天然相反,所以必须用两套记号区分。

一个特别实用的检验方法:写公式的时候检查等式两边是否"平衡"。如果左边是一个自由上标,右边也必须有一个自由上标;如果两边同位置出现了同一个自由指标但一个在上一个在下,那这个式子基本就是错的(除非中间显式使用了度规升降)。

注意:在直角坐标系下做练习时,上标下标可以混着写不太会出错,但一定要养成标注的习惯。等到你处理球坐标或一般的非正交基,这个习惯能救你半条命。

我在带实验的时候就遇到过这种情况:某位同学在球坐标下推导应变,把所有指标都写成下标,结果算出来的剪切分量符号全反了。检查了半天代码,最后发现是方程本身就写错了。这类错误非常隐蔽,因为数值上"看起来像对的"。

2.2 哑指标、自由指标,以及怎么一眼看出求和范围

这是我最想强调的一节,因为它是所有张量计算的"检查表"。

规则其实很简单:在一个单项式里,某个指标出现两次(一次上标、一次下标),就对这个指标从 1 到 n 求和。这个被求和的指标叫哑指标(dummy index),求和完就消失了。剩下的、在等式两边都出现的指标叫自由指标(free index),它决定这个式子是几个方程。

举几个例子,请你跟我一起在脑子里数:

  • A^i B_i 里,i 出现两次,求和,结果是标量。自由指标为零。
  • A^i{}_{jk} B^j 里,j 出现两次,求和;i 和 k 各出现一次,是自由指标。结果是一个带 i、k 两个自由指标的对象。
  • A_{ijk} B^{jk} 里,j 和 k 都出现两次,都求和,结果对 i 是自由的,是一个 1 阶张量。

这里有几个新手几乎必踩的坑,我列出来供你对照:

第一,同一个指标出现三次以上,表达式就是错的。比如 A_{ii} B_i,i 出现了三次,这在标准爱因斯坦约定下没有意义。有人会把它理解成"先求和 i,再乘 B_i 再求和"——那是两步操作,必须用括号或者引入新指标写清楚。

第二,哑指标可以随便改名,但只能改没被占用的名字。A^i B_i 等于 A^j B_j,这个自由改名是推导中最常用的技巧,也是化简公式的核心手段。但你不能把 A^i B_i C_i 里的某一个 i 改成 j 之后还理直气壮,因为原式本身就是错的。

第三,求和只在单项式内部进行,跨项不求和。这一点在相对论性记号下是硬规定,很多初学者会把 (A_i + B_i) C_i 误当成对两个 i 都求和,实际上它的展开是 A_i C_i + B_i C_i,两个求和是分开的。数值上碰巧一样,但写成 A_i C_i + B_i C_i 才是正确的表达方式。

我自己的习惯是:每写下一个稍微复杂的公式,就用笔在指标下面画圈,出现两次的画一个圈标记求和,出现一次的在等号两边核对是否一致。这个动作看起来很笨,但它让我在一次长达三页的推导中零错误。指标检查是廉价的,重算一遍是昂贵的。

2.3 用指标重写你最熟悉的那些运算

把常见运算翻译成指标式,你会发现它们其实是一套东西的不同形态。这个表格我建议你手工抄一遍,抄完基本就记牢了。

运算名称指标表达式说明
向量点积A^i B_i缩并掉唯一的指标,得到标量
向量外积A^i B^j两个自由指标,得到 2 阶张量
矩阵乘向量A^i{}_j v^j吃掉一个方向指标,得到新向量
矩阵乘矩阵A^i{}_k B^k{}_j共享 k 指标并缩并
矩阵的迹A^i{}_i上下指标相同并缩并
双线性形式x^i A_{ij} y^j连续两次缩并,得标量
逐元素积A_{ij} B_{ij} 不对,应为 A_{ij} B^{ij} 整体再取注意这是整体求和,不是逐元素

最后一行我特意写得不那么正式,因为这是个高频误区:A_{ij} B_{ij} 在爱因斯坦约定下是一个标量(全部求和),而不是逐元素相乘得到的矩阵。要在代码里表达逐元素积(Hadamard 积),指标式必须写成 C_{ij} = A_{ij} B_{ij},等号左边有自由指标,右边同样的自由指标出现,这是"逐元素";而孤立地写 A_{ij} B_{ij} 没有自由指标,它就是全和。

这个区别在写einsum的时候对应得非常直接:

import torch A = torch.randn(3, 4) B = torch.randn(3, 4) torch.einsum('ij,ij->', A, B) # 全部求和,输出标量 torch.einsum('ij,ij->ij', A, B) # 逐元素积,输出 3x4

一个->右边写不写指标,结果完全不同。我见过不止一个人在调试时把这两种情况搞混,然后花了半小时怀疑人生。记住:->右侧出现的指标就是自由指标,没出现的就求和。

3. 爱因斯坦求和约定:把 Σ 藏起来之后

爱因斯坦当年的动机非常实际:广义相对论的公式里 Σ 号太多,写起来像刷墙,看起来像噪声。于是他把求和号省掉,只靠指标出现次数来隐含表达求和。这个约定后来被整个物理学界接受,现在又通过einsum这种 API 反向输入到了深度学习领域。

但"省略"是有代价的。Σ 号显式存在的时候,你一眼能看出哪些指标被求和、范围是多少。省略之后,这些信息全靠约定和上下文,一旦写错,公式看起来还挺像那么回事。所以接下来这几节,我要把约定的边界和禁忌说清楚。

3.1 约定规则与几条不能碰的红线

标准爱因斯坦求和约定的完整表述是这样的:当一个单项式中某个指标字母恰好出现两次时,隐含对该指标在其全部取值范围内求和(通常是 1 到 n);出现一次则为自由指标。

从这条规则派生出几条红线:

  • 一个指标不能出现三次及以上。如果确实需要三重求和,必须引入辅助指标或者显式写 Σ。
  • 上下标必须配对。在一般坐标下,求和的一对指标必须一上一下。如果你写 A_{ii},在欧氏直角坐标下没问题(那其实是在用度规),但在曲线坐标下这是个需要说明的操作。
  • 求和指标的字母可以随意更换,但不能与其他已有指标冲突。这条听起来琐碎,但在长推导里是出错的高发区。我通常的习惯是:求和指标用 i, j, k, l,自由指标用 a, b, c, d,字母分组使用,避免视觉混淆。
  • 跨项不求和。前面已经强调过,但值得再说一次,因为它在化简分式表达式时特别容易犯。

还有一个更隐蔽的规定:在同一个表达式的不同项里,自由指标的集合和位置必须完全一致。比如 A^i_j + B^i_j 是合法的,A^i_j + B_i{}^j 就不合法(除非你显式地用度规把指标挪过去)。这条规则实际上是"张量方程必须协变"的直接体现,它保证了方程在任何坐标系下形式相同。

提示:如果你在做推导时发现等式两边的自由指标对不上,别急着改字母,先怀疑是否有项被漏掉或者写错。至少有一半的情况是漏项。

3.2 用 einsum 把公式逐字翻译成代码

这是我觉得张量符号体系最爽的一个应用场景。你不需要在脑子里走一遍求和顺序,只要把指标式照抄成字符串就行。

看一个具体的例子。假设你要实现一个"双线性池化"类似的操作,数学式是 s = x^i A_{ij} y^j,用einsum写出来是:

import torch torch.manual_seed(0) x = torch.randn(8) A = torch.randn(8, 8) y = torch.randn(8) s = torch.einsum('i,ij,j->', x, A, y) print(s.shape) # torch.Size([])

我特别喜欢这个例子的原因是,'i,ij,j->'这串字符几乎就是 x^i A_{ij} y^j 的直译:i、ij、j 分别对应三个操作数的指标,->后面为空表示全部缩并成标量。你不需要知道einsum内部是先做矩阵乘还是先做点积,它自己会处理。

再举几个更实操的:

B, N, C = 4, 16, 32 X = torch.randn(B, N, C) W = torch.randn(C, C) # 对每个 batch、每个 token 做线性变换 Y = torch.einsum('bnc,cd->bnd', X, W) # 注意力里的分数矩阵:Q 和 K 都形如 (B, H, N, D) Q = torch.randn(2, 8, 16, 32) K = torch.randn(2, 8, 16, 32) scores = torch.einsum('bhid,bhjd->bhij', Q, K)

第二个例子里的'bhid,bhjd->bhij'是标准的注意力打分公式。我第一次看到这行代码的时候有点懵,但一旦把它写成指标式 Q^{bhid} K_{bhjd} → S^{bhij},就非常清楚:d 指标被缩并掉,保留 b、h、i、j 四个自由指标。指标在哪个位置、要不要保留,全由->右边控制。

一个必须提醒的细节:einsum支持隐式模式,也就是不写->。比如torch.einsum('ij,jk', A, B),它会自动把重复指标缩并、把剩下指标按字母序排列输出。这个机制很省事,但也很危险——你必须在脑子里跑一遍字母排序才知道输出形状是什么。我的建议是:生产代码里永远写显式->多敲几个字符,换来的是可读性和零歧义。

3.3 常用运算的指标式与 einsum 对照

为了让你能直接抄,我把高频操作整理成一张表。左中右分别是数学式、einsum字符串、以及是否可以直接用原生 API 替代。

数学式einsum 字符串原生 API 等价
A_{ij} B^{jk}'ij,jk->ik'A @ B
A_{ij} B_{ji}'ij,ji->'(A * B.t()).sum()
A_{ii}'ii->'torch.trace(A)
A_{ii}(不求和,取对角)'ii->i'torch.diagonal(A)
A_{ij} 转置'ij->ji'A.t()
A_{ij} 全和'ij->'A.sum()
A_i B_j'i,j->ij'torch.outer(A, B)
A_{ij} B_{ij}'ij,ij->'(A * B).sum()
A_{ij} B_{ij}(逐元素)'ij,ij->ij'A * B
批量矩阵乘 A_{bij} B_{bjk}'bij,bjk->bik'torch.bmm(A, B)

这张表里有一行值得单独说:'ii->i'。在 PyTorch 里,这个操作返回的是原张量的视图,不是拷贝。也就是说,你修改对角元素会同时修改原张量;而torch.diagonal的行为是类似的,但在某些版本上对非连续张量的表现有差异。这种细节平时不重要,做原地运算(in-place)的时候就是 bug 的来源。我在一次梯度累积的实现里就被这个坑过:本以为改的是副本,结果把原始权重改了,训练直接跑飞。

另外那张表最后一列我的建议是:能用原生 API 就别用einsum。原因很简单,torch.matmultorch.bmm这些是高度优化的算子,底层可能调用了 cuBLAS 或者特定的 kernel;而einsum在某些情况下会退化成通用的逐元素累加,性能差好几倍。einsum的价值在于"我懒得查 API"和"这个运算太特殊没有现成的",而不是"用它写什么都更优雅"。

4. 两个万能砖块:delta 与 epsilon

如果只允许我教两个符号,我会选 Kronecker delta 和 Levi-Civita 符号。原因无他:在实际推导中,几乎所有化简都靠这两个东西。delta 负责"搬指标",epsilon 负责"处理反对称结构"。把它们练熟,你的推导速度会提升一个量级。

4.1 Kronecker delta 的指标替换功能

定义很简单:δ^i_j 当 i = j 时等于 1,否则等于 0。但它的作用远不止"单位矩阵"这么简单。

核心性质是指标替换:δ^i_j A^j = A^i。你把它理解成一个"过滤器",它把 A 的第 j 个分量按上面那个公式搬到了 i 的位置,求和过程中只有 i = j 的那一项活下来。这个操作在化简公式时无比好用。

再来两个常用的性质:

  • δ^i_i = n,其中 n 是指标的取值范围(空间的维数)。这是个经常被忽略的式子,比如在三维空间里 δ^i_i = 3。
  • δ^i_j δ^j_k = δ^i_k,这是单位矩阵自乘等于自身在指标语言下的表达。

举个例子体验一下。假设你要化简 A^i{}_j δ^j_k B^k{}_l,按顺序替换:先把 j 换成 k(或者说把 δ^j_k 拿去把 A 里的 j 换成 k),得到 A^i{}_k B^k{}_l。整个过程就是"把同一个字母对齐、然后消掉 delta"。用熟了完全是机械操作。

注意:δ 的上下标位置决定它把指标搬到哪。δ^i_j A^j = A^i 是把下标搬成上标;如果是 δ^i_j A_i,那结果是 A_j,方向相反。这在做升降指标的时候必须看清楚。

4.2 Levi-Civita 符号与叉积、行列式

ε_{ijk} 的定义是:全反对称,ε_{123} = 1。也就是说,指标是 1,2,3 的偶排列时为 1,奇排列时为 -1,有重复指标时为 0。三维下它有三个独立非零分量(1,2,3 及其偶排列)。

它最经典的用途是写叉积:

(a × b)i = ε{ijk} a^j b^k

你看,一个向量叉乘,用指标式写出来就是这么一行,不需要"右手定则"之类的文字说明。三个坐标分量一一展开,和高中课本完全一致。

它另一个用途是写行列式:

det(A) = ε_{ijk} A^i{}_1 A^j{}_2 A^k{}_3

或者更常见的形式 det(A) = ε_{ijk} A^i{}_l A^j{}_m A^k{}_n ε^{lmn} / ……等各种变体。这个式子在证明"行列式在基变换下的行为"时非常关键,因为它把行列式的反对称性用指标完全暴露出来了。

实操里我最常用 epsilon 的地方是化简三重积和旋度相关表达式。比如 a·(b × c) 这个混合积,用指标写就是 ε_{ijk} a^i b^j c^k,一眼就能看出它对任意两个向量交换都变号——这就是混合积的反对称性,用文字描述要写一大段,用指标一行搞定。

4.3 epsilon-delta 恒等式与它的实战价值

这是张量运算里最值得背下来的公式之一:

ε_{ijk} ε_{ilm} = δ_{jl} δ_{km} - δ_{jm} δ_{kl}

它的推广版本(缩并掉不同数量的指标)是:

  • ε_{ijk} ε_{ijl} = 2 δ_{kl}
  • ε_{ijk} ε_{ijk} = 6

这三个式子建议直接背,推导可以事后补。它们的价值在于把所有含叉积的表达式化成不含 epsilon 的形式

我给你演示一个经典推导:三向量叉积公式 a × (b × c) = b(a·c) - c(a·b)。

写成指标式,左边第 i 个分量是:

[a × (b × c)]i = ε{ijk} a^j (b × c)^k = ε_{ijk} a^j ε_{klm} b^l c^m

把两个 epsilon 里重复的 k 缩并,利用恒等式 ε_{ijk} ε_{klm} = δ_{il} δ_{jm} - δ_{im} δ_{jl},代回去:

= (δ_{il} δ_{jm} - δ_{im} δ_{jl}) a^j b^l c^m = a^j b^i c^j - a^j b^j c^i = b^i (a·c) - c^i (a·b)

推导完毕。全程机械操作,没有一步需要"灵光一现"。这就是指标语言的力量:把几何直觉转化成可以盲执行的代数操作。我当年学矢量分析的时候,这个公式是死记硬背的;学会了 epsilon-delta 恒等式之后,它变成了一个三行的练习。

5. 动手实操:从手写指标到 PyTorch 跑通

前面四节基本是纸上功夫,这一节我们全部落到代码。我的习惯是:每一个手推的公式,都用einsum实现一遍,再跟原生 API 的结果对比。这样既能验证推导,又能建立"符号-代码"之间的映射直觉。

5.1 环境准备与基础张量构造

假设你已经装好了 PyTorch,先做版本检查。这个动作看起来多余,但einsum在不同版本上对非连续张量、对 ellipsis 的处理确实存在差异,写代码前确认版本能省很多事。

import torch import numpy as np print(torch.__version__) print(torch.cuda.is_available()) # 有卡的话会返回 True torch.manual_seed(42) # 一阶:向量 v = torch.arange(6, dtype=torch.float32) # 二阶:矩阵 M = torch.arange(12, dtype=torch.float32).reshape(3, 4) # 三阶:批量,比如 batch 里的特征图 T = torch.randn(2, 3, 4) print(v.shape, M.shape, T.shape, T.stride())

跑完之后重点看两样东西:形状(shape)和步长(stride)。形状决定"逻辑上有几维、每维多长",步长决定"物理内存怎么排"。后面第五小节会专门讲这个。

另外提醒一句 dtype。默认的浮点构造可能是 float32,但在某些旧代码或 numpy 转换场景下会拿到 float64。float64 的张量显存占用是 float32 的两倍,而且很多 GPU 算子对 float64 支持很差。我在一次迁移实验里就是因为误用了 float64,速度慢了四倍,排查了半天才发现是数据类型的问题。

5.2 用 einsum 实现典型张量运算并交叉验证

下面这段代码我一共做了六件事,每一件都同时给出einsum写法和原生写法,最后断言两者结果一致。建议你直接抄下来跑一遍,感受一下两种写法之间的对应关系。

import torch torch.manual_seed(0) A = torch.randn(3, 4) B = torch.randn(4, 5) x = torch.randn(4) y = torch.randn(4) # 1) 矩阵乘法:C_ik = A_ij B_jk C1 = torch.einsum('ij,jk->ik', A, B) C2 = A @ B assert torch.allclose(C1, C2, atol=1e-6) # 2) 矩阵乘向量:y_i = A_ij x_j v1 = torch.einsum('ij,j->i', A, x) v2 = A @ x assert torch.allclose(v1, v2, atol=1e-6) # 3) 外积:O_ij = x_i y_j O1 = torch.einsum('i,j->ij', x, y) O2 = torch.outer(x, y) assert torch.allclose(O1, O2, atol=1e-6) # 4) 迹与对角 S = torch.randn(5, 5) tr = torch.einsum('ii->', S) dg = torch.einsum('ii->i', S) assert torch.allclose(tr, torch.trace(S), atol=1e-6) assert torch.allclose(dg, torch.diagonal(S), atol=1e-6) # 5) 双线性形式:s = x_i A_ij y_j s1 = torch.einsum('i,ij,j->', x, A[:, :4] if A.shape[1] == 5 else A, x[:A.shape[1]]) # 为免形状不匹配,单独构造一个方阵 Aq = torch.randn(4, 4) s2 = torch.einsum('i,ij,j->', x, Aq, x) s3 = x @ Aq @ x assert torch.allclose(s2, s3, atol=1e-6) # 6) 批量矩阵乘:C_bik = A_bij B_bjk Xb = torch.randn(8, 3, 4) Yb = torch.randn(8, 4, 5) Z1 = torch.einsum('bij,bjk->bik', Xb, Yb) Z2 = torch.bmm(Xb, Yb) assert torch.allclose(Z1, Z2, atol=1e-6) print("所有交叉验证通过")

跑通之后你会发现一个有意思的现象:einsum那几行代码,跟左边的指标式几乎可以逐字符对照。这就是我一直强调的"符号即代码"。当你下次遇到一个没见过现成 API 的运算,比如"两个四阶张量在中间两个指标上缩并",直接写'ijkl,klmn->ijmn'就行,不用去查文档里有没有对应的函数。

顺手提一个优化方向:einsum的默认缩并顺序不一定是最优的。当操作数有三个以上时,不同缩并顺序的计算量差异可能达到几个数量级。这时候可以用opt_einsum库来寻找最优路径,接口基本兼容。

# 需要先安装:pip install opt_einsum from opt_einsum import contract # 三个张量的链式缩并,opt_einsum 会自动寻找较优的顺序 # 这在张量网络、多体物理的代码里几乎是标配

5.3 形状变换与内存布局:为什么 view 会突然报错

这是工程侧最容易翻车的地方,而且它跟指标思维直接相关。在数学上,指标的排列顺序无所谓;在内存里,顺序决定了数据怎么排布,进而决定了哪些操作是零成本、哪些要拷贝。

先说结论:PyTorch 的张量有两个属性,shapestrideshape是逻辑视图,stride是物理地址的跳跃规则。当 stride 和 shape "匹配"(也就是满足连续条件)时,view可以零拷贝地改变逻辑形状;一旦不匹配,view就会报错。

import torch x = torch.arange(12).reshape(3, 4) print(x.stride()) # (4, 1) print(x.is_contiguous()) # True y = x.t() print(y.shape) # torch.Size([4, 3]) print(y.stride()) # (1, 4) 注意这里变了 print(y.is_contiguous()) # False # y.view(-1) 会直接报错:view size is not compatible with input tensor's # y.reshape(-1) 则不会报错,因为它会在必要时悄悄拷贝一份 z = y.reshape(-1) print(z.shape, z.is_contiguous())

这里的关键理解是:转置没有移动任何数据,它只是交换了 stride。所以y的"逻辑顺序"和"内存顺序"不再一致。view要求内存连续,所以它拒绝工作;reshape更宽容,它会先判断能不能 view,不能的话就调用contiguous()拷一份新的。

提示:reshape的"宽容"是有代价的。如果你在高频循环里反复reshape一个非连续张量,会产生大量隐式拷贝,性能断崖式下跌。这时候显式写x.contiguous().view(...)反而更好排查,因为你知道拷贝发生在哪里。

再来说permute。它和transpose是同一类操作,都是重排指标顺序。用指标语言来理解特别自然:x.permute(0, 2, 1)就是把形状为 (a, b, c) 的张量,指标从 (i, j, k) 变成 (i, k, j)。它同样不拷贝数据,只改 stride。所以permute之后紧跟着view,大概率会报错。

有一个常用的组合模式值得记住:

# 把 (B, C, H, W) 转成 (B, H, W, C) 再拉平成 (B, H*W, C) x = torch.randn(4, 32, 16, 16) x = x.permute(0, 2, 3, 1) # (B, H, W, C) x = x.contiguous().view(4, 16 * 16, 32)

这三行的顺序不能乱。先用permute调整逻辑顺序,再用contiguous让它物理上连续,最后view合并后两个指标。如果你把contiguous省掉,第二行可能不会立刻报错,但后面某个算子遇到非连续输入时会突然抛异常,或者更糟——跑得对但很慢。

5.4 张量网络视角:把缩并画成图

张量网络这个概念听起来吓人,其实核心思想就一条:每个张量是一个节点,每条边是一个被缩并的指标,图的结构决定了缩并的复杂度

矩阵乘法 C_ik = A_ij B_jk 是最简单的例子:两个节点 A 和 B,共享一条边 j,输出节点有两条自由边 i 和 k。批量矩阵乘 C_bik = A_bij B_bjk 就是在这张图上多了一条 b 边,而 b 是贯穿的、不被缩并的。

一旦你习惯这种画法,很多性能问题会变得直观。比如三个张量的链式缩并 A_ij B_jk C_kl,你可以先缩并 j 再缩并 k,也可以先缩并 k 再缩并 j。这两种顺序在数学上等价,但计算量可能差好几个数量级。判断依据是"中间结果的大小":每次缩并后留下的自由指标越多,中间张量就越大,显存和算力开销就越恐怖。

这也是opt_einsum这类工具存在的理由:它会搜索缩并顺序,找出中间张量最小的路径。我在做注意力机制的性能优化时,曾经把一个四张量缩并的路径从"先缩并大的"改成"先缩并小的",显存峰值降了将近一半。

顺带说一句"记忆张量"这个词。在工程语境里,它通常指的是被显式缓存下来、跨多个计算步骤复用的中间张量。最典型的就是自回归推理时缓存的那部分键值张量——每一步都要读它、追加它,但不重新计算它。它的设计目标是"用空间换时间",代价是显存占用。所以如果你在显存告急的情况下看到类似"memory tensor 占用过高"的日志,处理思路不是优化算子,而是考虑降低批量大小、缩短序列长度,或者做分块的缓存管理。这个概念跟数学上的张量没关系,纯粹是个工程术语,别被名字骗了。

6. 常见问题与排查技巧实录

前面五节讲的是"应该怎么做",这一节讲"出错的时候怎么找"。我把这些年踩过的坑整理成三类,按排查成本从低到高排列。

6.1 指标类错误的排查清单

指标写错是最难查的一类错误,因为代码会正常跑完,只是结果不对。我的排查顺序是这样的:

现象可能原因检查方法
等式两边自由指标数量不一致漏写或错写指标逐项数指标出现次数
某个指标出现三次需要引入辅助指标拆成两步缩并,或改名
结果维度对不上上下标位置错误检查是否显式用了度规
结果差一个符号epsilon 指标顺序反了检查排列奇偶性
结果差一个维度因子遗漏了对角项的 n检查 δ^i_i 是否被正确求值
某个分量恒为零被求和的指标范围写错确认指标取值范围是否为 1..n

这里面我觉得最值得展开的是"差一个维度因子"。δ^i_i = n 这个式子,在三维空间里是 3,在四维时空里是 4。如果你在推导时把它当成 1,结果会差好几倍,而且这种错误在小规模测试里往往看不出来——因为你测的是标量,没有量纲参照。

我个人的习惯是:每完成一个推导,至少用两个不同的指标命名方式重写一遍,检查最终表达式是否一致。这个做法有点笨,但它拦下过我好几个错误。

6.2 PyTorch 里的高频坑

工程侧的坑大多集中在"形状"和"连续性"上,我把最常遇到的几个列一下。

第一个坑是einsum的隐式模式。前面提过,这里再强调一次:torch.einsum('ij,jk', A, B)不会报错,但它的输出顺序是按字母排序自动决定的。如果你的代码依赖输出形状,某次换了张量维度顺序,输出形状可能就变了,然后错误会在下游很远的地方才暴露出来。统一写显式->是唯一的解法。

第二个坑是 dtype 不一致。einsum不会自动帮你做类型提升,float32 和 float64 混用会报错或者静默提升,影响性能。调试时先打印所有输入张量的 dtype。

第三个坑是非连续输入。einsum本身能处理非连续张量,但性能会下降。如果你的瓶颈在einsum上,先检查输入是不是转置或 permute 之后的视图,必要时先contiguous()

第四个坑是把einsum用在原地操作场景。einsum不支持 in-place 输出,所以如果你在做需要原地修改的层,就别指望它,用matmul配合add_之类的写法。

第五个坑是广播和 ellipsis 混用。'...ij,...jk->...ik'这种写法很方便,但它对省略号覆盖的维度有隐含要求:两边的批次维度必须能广播。如果你不确定,就老老实实把维度写全。

我在一个多模态项目里就吃过这个亏:视觉特征和文本特征的批次维度顺序不同,用 ellipsis 写的einsum没有报错,但实际配对错了,结果是"图 A 配文 B",损失还能降,模型就是学不好。这种 bug 只能靠单元测试拦下来——每写完一个不平凡的einsum,立刻用一个已知答案的小张量验证一遍。

6.3 性能与显存:别在小事上翻车

最后聊一点实操中容易被忽略的成本问题。

先算一笔简单的账。一个形状为 (1024, 1024, 64) 的 float32 张量,元素个数是 1024 × 1024 × 64 = 67,108,864,每个元素 4 字节,总共约 256 MiB。如果你在前向过程中保留三四个这样的中间张量用于反向传播,显存会在一瞬间被吃光。这不是理论推演,是我在调大 batch size 时反复撞的墙。

所以写张量运算时,我的三条经验是:

一是优先用原生 APImatmulbmmconv2d这些底层都有专门优化,比通用einsum快得多。只有当运算确实没有现成实现时才上einsum

二是注意中间张量的大小。多张量缩并时,先缩并"输出维度小"的那一对指标,能显著降低中间结果的规模。

三是警惕隐式拷贝reshapecontiguous、类型转换、CPU-GPU 之间的搬运,这些都是隐式开销的来源。用torch.cuda.memory_allocated()之类的接口定期看看显存占用,比事后猜要靠谱。

提示:调试显存问题时,先把 batch size 设成 1,跑一遍完整前向反向,记录峰值占用,再线性外推。这比直接上大 batch 然后 OOM 要高效得多。

我在实际使用中还有一个体会:张量运算的性能问题,百分之八十出在"不该拷贝的地方拷贝了"和"该缓存的没缓存"这两件事上,真正需要换算法的场景反而少。所以与其一上来就研究手工写 CUDA kernel,不如先把形状、连续性、缩并顺序这三件事理顺。

说到最后,我个人在张量这件事上的体会是:概念、符号、代码这三样东西必须轮着练。只读概念会飘,只练符号会僵,只写代码会盲。我自己的做法是每学一个新公式,先在纸上手推一遍,再用einsum实现,最后用一个反例验证它——比如故意打乱指标顺序,看结果是不是按预期变了。这个"手推—实现—反证"的三步循环,比读十篇教程都管用。至于更深的张量分解、张量网络,等这套基本语言练到不假思索,再往上走会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询