1. 从“多维数组”说起:张量到底是个什么东西
很多人第一次听到“张量”这个词,脑子里浮现的画面大概是数学课本里密密麻麻的公式和上下标。我当初也是这么想的,直到后来做图像处理和推荐系统,天天跟各种维度的数据打交道,才慢慢意识到:张量其实就是一个“多维数组”的数学化表达,只不过它比数组多了几层物理和几何上的含义。
先给一个最直白的定义:张量是一个可以用多维数组表示、并且遵循特定坐标变换规则的多重线性映射。这句话拆开来看,前半句“多维数组”是它的数据形态,后半句“坐标变换规则”是它的数学灵魂。如果你只关心写代码,那把它当成多维数组完全够用;但如果你想理解为什么深度学习框架叫“TensorFlow”而不是“MatrixFlow”,那就得往深里挖一层。
举个生活中的例子。标量是一个数,比如温度25摄氏度,这是0阶张量。向量是一列数,比如一个三维空间中的速度(3, 4, 5),这是1阶张量。矩阵是一张二维表,比如一张灰度图像的像素值,这是2阶张量。再往上,彩色图像有长、宽、通道三个维度,就是3阶张量。视频又多了一个时间维度,变成4阶张量。所以你会发现,阶数就是描述数据所需的独立索引个数。
但这里有个容易混淆的点:张量的“阶”和矩阵的“秩”不是一回事。矩阵的秩是线性无关的行或列的最大数目,而张量的阶是维度的数量。我见过不少初学者把这两个概念搞混,结果在看论文时一头雾水。记住,阶对应的是“有几个下标”,秩对应的是“信息冗余程度”。
那为什么不用“多维数组”这个词,非要叫“张量”呢?关键在于变换不变性。一个多维数组在不同坐标系下,元素值会变,但张量作为一个几何对象,它描述的物理量本身不依赖于坐标系的选择。比如应力张量,无论你怎么旋转坐标系,它描述的受力状态是客观存在的。这种“坐标无关性”才是张量在物理学和工程学中备受推崇的根本原因。
在深度学习领域,张量的物理含义被弱化了,更多是作为计算图的载体。PyTorch和TensorFlow中的Tensor,本质上就是带自动微分能力的多维数组。你可以对它做切片、广播、矩阵乘法、卷积等操作,框架会自动追踪梯度。所以如果你是从AI入门的,先把张量理解为“可求导的多维数组”也没问题,等用到物理仿真或几何计算时,再回头补坐标变换那一课。
2. 阶数、形状与轴:读懂张量的三个核心参数
刚接触张量时,最容易犯迷糊的就是“这个张量到底长什么样”。其实只要抓住三个参数——阶数、形状、轴——就能把任何一个张量描述清楚。
2.1 阶数决定“有几个索引”
阶数也叫秩(rank),但为了避免和矩阵的秩混淆,我习惯叫它“阶”。0阶是标量,1阶是向量,2阶是矩阵,3阶及以上统称高阶张量。在代码里,你可以用ndim属性查看。比如一个形状为(3, 4, 5)的张量,它的阶数是3,因为需要三个索引才能定位到一个具体元素。
这里有个实操中的小技巧:看括号嵌套层数。5是0阶,[1, 2, 3]是1阶,[[1, 2], [3, 4]]是2阶,[[[1]]]是3阶。数左括号的连续层数,基本不会错。
2.2 形状描述“每个维度有多长”
形状是一个元组,比如(2, 3, 4)表示第一维长度2、第二维长度3、第三维长度4。形状的乘积就是张量中元素的总数。这个参数在实际操作中极其重要,因为绝大多数报错都和形状不匹配有关。
我整理了一个常见形状对照表,方便你快速查阅:
| 数据场景 | 典型形状 | 阶数 |
|---|---|---|
| 单个数值 | () | 0 |
| 一维信号 | (100,) | 1 |
| 灰度图像 | (28, 28) | 2 |
| 彩色图像 | (224, 224, 3) | 3 |
| 批量彩色图像 | (32, 224, 224, 3) | 4 |
| 视频片段 | (16, 32, 224, 224, 3) | 5 |
注意:不同框架对通道维度的位置约定不同。PyTorch默认通道在前,即(N, C, H, W);TensorFlow默认通道在后,即(N, H, W, C)。混用框架时这是高频翻车点。
2.3 轴是“操作的方向”
轴就是维度的编号,从0开始。比如形状(2, 3, 4)的张量,轴0长度2,轴1长度3,轴2长度4。很多操作都需要指定轴,比如求和、拼接、转置。指定轴的本质是“沿着这个方向做折叠或重排”。
举个具体例子。假设有一个形状为(2, 3)的矩阵:
[[1, 2, 3], [4, 5, 6]]沿轴0求和,得到[5, 7, 9],形状变成(3,),因为把两行对应位置加起来了。沿轴1求和,得到[6, 15],形状变成(2,),因为把每行的三个数加起来了。你可以这样记:沿哪个轴求和,哪个轴就消失。
2.4 广播机制:形状不同也能运算
广播是张量运算中极其重要又容易被忽视的机制。当两个形状不同的张量做逐元素运算时,框架会尝试自动扩展维度,使它们形状兼容。规则是从右往左对齐,每个维度要么相等,要么其中一个为1,要么其中一个不存在。
比如形状(3, 1)和(1, 4)相加,结果形状是(3, 4)。形状(2, 3, 4)和(3, 4)相加,后者会被自动扩展成(1, 3, 4),再广播成(2, 3, 4)。这个机制让代码简洁很多,但也容易埋下隐患——有时候你以为在算A,实际上广播出了完全不符合预期的结果。
我的经验是:每次做完运算,打印一下形状。尤其是在调试复杂模型时,形状对不上比数值错误更难排查。
3. 张量运算的底层逻辑:为什么这样设计
张量的运算看起来五花八门,但归根结底可以分成几大类:逐元素运算、归约运算、线性代数运算、形状变换运算。理解每一类的设计意图,比死记API有用得多。
3.1 逐元素运算与归约运算的对偶关系
逐元素运算就是两个形状相同的张量对应位置做加减乘除,比如ReLU激活、加法偏置。归约运算则是把某个轴上的多个值合并成一个值,比如求和、求均值、求最大值。
这两类运算其实是对偶的:逐元素运算是“保持形状”,归约运算是“消除维度”。在神经网络中,前向传播大量使用逐元素运算,而池化层和全局平均池化则是典型的归约运算。理解这一点,你就能明白为什么池化层能降低特征图尺寸——它本质上是在空间轴上做归约。
3.2 矩阵乘法与爱因斯坦求和约定
矩阵乘法是张量运算中最核心的一种。对于2阶张量,形状(m, n)和(n, p)相乘得到(m, p)。对于高阶张量,矩阵乘法可以指定在哪些轴上做收缩。PyTorch中的torch.matmul和torch.einsum就是干这个的。
爱因斯坦求和约定是一种更通用的表达方式。比如ij,jk->ik就等价于矩阵乘法。刚开始看可能觉得反直觉,但用熟了之后,它能用一行代码表达复杂的多维收缩,比写循环优雅得多。我建议你在实现注意力机制时尝试用einsum重写一遍,会对张量收缩有全新的认识。
3.3 形状变换:view、reshape与permute的区别
这三个操作经常被混用,但它们的语义不同。view要求张量在内存中是连续的,否则会报错;reshape更宽容,必要时会复制数据;permute则是真正改变轴的顺序,不改变内存布局但改变逻辑视图。
我踩过的一个坑是:对permute后的张量直接调用view,结果报错。原因是permute只改变了步长信息,底层内存并没有重排,所以不满足view的连续性要求。正确做法是先.contiguous()再.view()。这个细节在实现多头注意力时特别容易遇到,因为经常需要把(batch, head, seq, dim)重排成(batch, seq, head, dim)。
3.4 自动微分:张量为什么需要计算图
深度学习框架中的张量之所以区别于普通数组,核心就在于它记录了计算历史,支持自动微分。每个张量有一个requires_grad标志,开启后,所有对它的操作都会被记录到计算图中。反向传播时,框架沿着计算图自动求导。
这里的关键设计是:前向传播时构建图,反向传播时释放图。所以如果你在训练循环中不小心保留了中间张量的引用,可能会导致显存泄漏。我习惯在每个batch结束后用del显式删除不再需要的中间变量,或者用torch.no_grad()包裹推理代码。
4. 从零实现一个张量类:把原理变成代码
光看概念容易飘,动手写一个简易张量类能帮你把很多细节钉死。下面我用Python实现一个支持基础运算和自动微分的迷你张量,代码不长,但涵盖了核心思想。
4.1 数据结构设计
import numpy as np class Tensor: def __init__(self, data, requires_grad=False): self.data = np.array(data, dtype=np.float64) self.requires_grad = requires_grad self.grad = None self._backward = lambda: None self._prev = set() @property def shape(self): return self.data.shape @property def ndim(self): return self.data.ndim这里用NumPy数组存数据,grad存梯度,_backward是一个闭包,负责把梯度传给上游。_prev记录前驱节点,用于构建计算图。
4.2 加法与乘法的前向和反向
def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + out.grad if other.requires_grad: other.grad = (other.grad or 0) + out.grad out._backward = _backward out._prev = {self, other} return out def __mul__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data * other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + out.grad * other.data if other.requires_grad: other.grad = (other.grad or 0) + out.grad * self.data out._backward = _backward out._prev = {self, other} return out加法的反向传播就是把梯度原样传回去,乘法的反向传播是梯度乘以另一个操作数的值。这就是链式法则在计算图上的具体体现。
4.3 反向传播的拓扑排序
def backward(self): topo = [] visited = set() def build_topo(v): if v not in visited: visited.add(v) for child in v._prev: build_topo(child) topo.append(v) build_topo(self) self.grad = np.ones_like(self.data) for v in reversed(topo): v._backward()拓扑排序保证每个节点的梯度在所有依赖它的节点处理完之后再计算。这个顺序不能乱,否则梯度会算错。实际框架中还会做很多优化,比如算子融合、内存复用,但核心逻辑就是这个。
4.4 实测中的几个意外
写完这个迷你张量后,我拿它跑了一个简单的线性回归,发现两个问题。第一,梯度累加没有清零机制,多次调用backward会不断累加。第二,没有实现广播的反向传播,当两个操作数形状不同时会出错。这两个问题在真实框架中都有对应处理:optimizer.zero_grad()负责清零,广播的反向需要对梯度做归约求和。
提示:自己实现一遍之后,再去看PyTorch的
autograd文档,很多之前看不懂的设计会突然变得清晰。
5. 张量在真实场景中的典型用法与踩坑记录
理论讲完了,接下来聊聊我在实际项目中用张量时踩过的坑和总结的经验。这部分内容在教科书里基本找不到,但每一个都是真金白银换来的。
5.1 图像批处理中的维度顺序陷阱
做图像分类时,数据加载器吐出来的张量形状是(N, H, W, C),但PyTorch的卷积层要求(N, C, H, W)。我一开始用permute转换,结果发现训练速度特别慢。后来才意识到,permute之后张量变得不连续,卷积底层做了隐式的内存拷贝。正确做法是用permute之后立刻.contiguous(),或者直接在数据加载阶段就转好。
另一个坑是归一化。如果你用ImageNet的均值和标准差做归一化,形状是(3,)的向量,而图像张量是(N, 3, H, W)。直接相减会触发广播,但广播的方向是从右往左对齐,所以(3,)会先被扩展成(1, 3, 1, 1),再广播到(N, 3, H, W)。这个行为是正确的,但如果你把均值形状写成(3, 1, 1),就会变成(1, 3, 1, 1)再广播,结果一样但容易让人困惑。我建议统一用(3,)的形状,简洁且不易出错。
5.2 序列模型中的掩码与广播
处理变长序列时,padding是家常便饭。假设一个batch中有三个序列,长度分别是5、3、2,padding到5之后形状是(3, 5)。计算注意力分数时,需要把padding位置的分数设成负无穷,这样softmax之后权重为0。
这里的关键是掩码张量的形状。如果掩码是(3, 5),注意力分数是(3, 5, 5),直接相加会广播成(3, 5, 5),但语义上每个query位置都应该看到同一个key掩码。所以掩码需要扩展成(3, 1, 5)再广播。我见过有人写成(3, 5, 1),结果掩码作用在了query维度上,模型完全学不到东西。掩码作用在哪个轴,一定要想清楚。
5.3 高阶张量的内存布局与性能
做视频理解时,张量形状经常是(N, T, C, H, W),5阶。这时候内存布局对性能影响很大。默认的行优先布局下,最后一个轴变化最快。如果你频繁在T轴和C轴之间做切片,缓存命中率会很低。
我的优化经验是:把最常访问的轴放在最后。比如如果模型主要做逐帧处理,就把形状改成(N, C, H, W, T),让时间轴在最后,这样每一帧的数据在内存中是连续的。当然,这需要配合框架的算子支持,不是所有操作都允许任意轴顺序。
5.4 梯度检查与数值稳定性
张量运算中的数值稳定性问题很隐蔽。比如计算log_softmax时,如果直接先softmax再log,遇到极小值会下溢成负无穷。正确做法是用logsumexp技巧。再比如计算方差时,如果均值很大,直接算E[x^2]-E[x]^2会 catastrophic cancellation,应该用两遍算法或Welford算法。
我习惯在实现新损失函数后,用torch.autograd.gradcheck做数值梯度检查。虽然慢,但能发现很多解析梯度推导中的错误。尤其是涉及除法、指数、对数的运算,手推梯度很容易漏项。
6. 张量学习的进阶路线与资源取舍
张量这个主题,入门容易精通难。市面上的资料从科普到硬核数学都有,怎么选取决于你的目标。
6.1 三条不同的学习路径
如果你的目标是做深度学习工程,那重点放在框架API、形状操作、广播规则、自动微分机制上。NumPy和PyTorch的官方文档是最好的起点,配合一些经典模型的源码阅读,比如Transformer的实现,基本就够用了。
如果你的目标是做科学计算或物理仿真,那必须补上张量分析、坐标变换、协变导数这些数学基础。推荐从向量微积分入手,再过渡到微分几何。这条路线陡峭但回报大,因为很多物理定律用张量形式表达极其简洁。
如果你的目标是做高性能计算,那需要理解张量的内存布局、分块策略、并行收缩算法。CUDA编程和cuBLAS文档是必读的,最好能动手写一些自定义kernel。
6.2 我个人的学习顺序
我当初是从NumPy入门的,把数组的轴、形状、广播玩熟之后,转到PyTorch做深度学习。后来因为项目需要做物理仿真,才回头补了张量分析和微分几何。这个顺序的好处是先用起来再深入,避免一开始就被数学符号劝退。
如果让我重新来一遍,我会在学完NumPy之后,花一周时间手写一个迷你自动微分框架,就像第4节那样。这个练习能打通“数据—运算—梯度”的完整链路,之后再学任何框架都会觉得理所当然。
6.3 常见误区与纠正
第一个误区是“张量就是多维数组”。这个说法在工程语境下没错,但会让人忽略坐标变换这一核心。纠正方法:找一本张量分析的入门书,看前两章即可。
第二个误区是“阶数越高越厉害”。实际上高阶张量计算代价指数增长,很多问题可以通过张量分解降阶处理。比如推荐系统中的用户-物品-上下文三阶张量,常用CP分解或Tucker分解来降维。
第三个误区是“广播总是安全的”。广播虽然方便,但会掩盖形状错误。我的建议是:在关键运算前显式检查形状,或者用torch.einsum这种显式指定轴的方式替代隐式广播。
最后分享一个我常用的调试技巧:在模型forward函数的关键位置插入
assert语句检查形状,比如assert x.shape[-1] == self.hidden_dim。训练时多花几秒钟,调试时能省几小时。
张量这个主题往深了走可以无穷无尽,但作为从业者,抓住“形状、轴、广播、自动微分”这四个关键词,就能覆盖日常工作中90%的场景。剩下的10%,等你真正遇到时再针对性补课,效率比从头啃数学书高得多。