很多人刚开始学AI,以为最难啃的是微积分。可真到写代码的时候会发现,最先拦住你的往往是线性代数——向量、矩阵、张量这些看起来像数学课的“抽象概念”,其实是每一个模型骨架里的“语法规则”。这篇进阶篇数学篇第6讲,就专门把这些计算掰开揉碎:向量、矩阵、张量在AI里到底怎么运算、怎么用,以及你写模型时踩过的那些形状错误、广播警告,根源在哪里。适合已经会用框架搭模型、但数学细节一直似懂非懂的同学,也适合准备面试前临时抱佛脚的人。
1. 为什么说向量、矩阵、张量是 AI 的“计算语法”
1.1 从单个数字到高维容器:标量、向量、矩阵、张量
一个普通的数字,在数学里叫标量,在代码里就是一个float或int。它没有任何方向,也没有“批量”的概念。但AI模型每次推理都要处理成千上万个样本,每个样本又包含几百上千个特征,如果还一个数字一个数字地管理,模型根本没法写。
于是有了向量:一串数字排成一行或一列,通常表示一个样本的特征。比如某个用户有年龄、收入、活跃度三个数值,就可以用一个三维向量[28, 15000, 0.8]表示。
矩阵则是二维的,像一个表格。它的第一个维度常常是“样本数”,第二个维度是“特征数”。一个包含100个用户、每人3个特征的输入,就是一个形状为(100, 3)的矩阵。
张量的定义更宽泛:它是任意维度的数组。三维张量可以表示视频或图像序列,四维张量常用来表示一批图片,五维、六维也偶有出现。对AI工程师来说,张量就是数据的通用容器,它的每一维都有一个名字,比如batch_size、channel、height、width。搞定了维度命名,张量就不可怕了。
| 数据类型 | 维度 | 常见形状例子 | AI中的典型对象 |
|---|---|---|---|
| 标量 | 0维 | ()或[] | 损失值、学习率 |
| 向量 | 1维 | (3,) | 单个样本的特征向量、词向量 |
| 矩阵 | 2维 | (100, 3) | 一批样本的特征表、单个线性层的权重 |
| 张量 | 3维及以上 | (16, 3, 224, 224) | 一批图片、注意力分数矩阵、序列批次 |
1.2 “计算语法”到底指什么
把数据装进张量只是第一步,真正的关键是运算规则。同样是一个三维数组,你可以把它当成“一批2D图片”,也可以当成“一条序列的隐藏状态”,这决定了维度怎么对位、乘法怎么执行。
我常把向量、矩阵、张量的运算比作英语的语法:单词是数据,语法是数据之间的组合规则。你背了再多单词,不知道主谓宾顺序也说不出完整句子。深度学习框架里,torch.matmul、torch.reshape、torch.transpose这些函数就是语法规则,而forward函数本质上就是把这些规则按一定顺序串起来,描述数据如何从输入变成输出。
而且,GPU 芯片内部对矩阵乘法和张量搬运做了大量定制优化,模型训练的速度瓶颈往往不在计算本身,而在于你写的张量运算是否贴合底层硬件习惯。后面我会专门讲内存布局和广播问题,这些看着不起眼,却直接影响训练速度和显存占用。
2. 向量运算:点积、范数和相似度计算到底在算些什么
2.1 点积:AI里出场率最高的亲密关系度量
向量点积的算法很简单:对应元素相乘,再把所有乘积加起来。写成公式就是a · b = a1*b1 + a2*b2 + ... + an*bn。在NumPy里一行代码搞定:
import numpy as np a = np.array([1.0, 2.0, 3.0]) b = np.array([2.0, 0.0, 1.0]) dot = np.dot(a, b) # 1*2 + 2*0 + 3*1 = 5点积的几何意义是“一个向量在另一个向量方向上的投影长度”乘以“另一个向量的长度”。所以它天然适合度量两个向量的相似度:方向越一致,点积越大;方向垂直时点积等于0;方向相反时点积为负。
这在AI里太常用了。自注意力机制的分数计算就是Q和K的点积除以维度的平方根;推荐系统算用户向量和物品向量的匹配度,用的也是点积或余弦相似度;文本检索里计算查询向量和文档向量的相关性,本质还是点积。可以说,大部分模型的“相关性”概念都是用点积定义的。
2.2 范数:向量长度是正则化和归一化的底层逻辑
范数用来衡量向量的大小。最常见的L2范数就是向量的欧几里得长度,公式是sqrt(a1^2 + a2^2 + ... + an^2)。L1范数则是绝对值之和。
L2范数在训练里最出名的应用是权重衰减,也就是给损失函数加上权重矩阵的L2范数平方,逼着参数不要长得太大。L1范数则常用来做稀疏化,让一部分特征权重变成0,相当于自动做特征选择。
归一化也是靠范数完成的。比如把向量a除以它的L2范数,得到单位向量。两个单位向量再做点积,结果就是余弦相似度,数值稳定在 -1 到 1 之间。很多向量召回模型在输出层之后都会加一个L2归一化,目的就是让点积结果只跟方向有关,跟向量长度无关。我自己做相似度模型的时候就踩过这个坑:不加L2归一化,训练时模型会把所有向量都拉长来提高点积分数,推理时结果完全不可控。加上归一化之后,训练稳定很多。
3. 矩阵运算:线性层、批量推理与参数压缩背后的乘法逻辑
3.1 矩阵乘法的形状对位:先记住这一条规则
矩阵乘法C = A @ B要求A的列数必须等于B的行数。结果C的形状是“A的行数 × B的列数”。比如A形状(m, n),B形状(n, p),结果就是(m, p)。
这个规则是初学者最容易卡壳的地方。看看这张表:
| A形状 | B形状 | 是否合法 | C形状 |
|---|---|---|---|
| (3, 4) | (4, 2) | 合法 | (3, 2) |
| (3, 4) | (3, 4) | 不合法 | 无 |
| (3, 4) | (4,) | 合法 | (3,) |
为什么会这样?因为矩阵乘法里,A的每一行要和B的每一列做点积,所以要求行的长度(也就是A的列数)和列的高度(也就是B的行数)一致。
从另一种视角看,矩阵乘法等于把B看作列向量的集合,A @ B等价于对A的每一行分别与B的所有列向量做点积。这一下子就把矩阵乘法拆回第2章的向量点积了,心理负担小很多。
3.2 线性层的本质:把“一组神经元”同时作用到“一批样本”上
全连接层的公式是Y = X @ W.T + b。其中X是输入,形状一般是(batch_size, input_dim);W是权重,形状是(output_dim, input_dim);W.T转置成(input_dim, output_dim);b是偏置,形状(output_dim,)。
为什么要转置?因为W的每一行本来就是一个神经元的权重向量。我们想让X的每个样本和W的每个神经元做点积,得到一个(batch_size, output_dim)的矩阵。如果直接X @ W,维度会变成(batch_size, input_dim),完全不是我们要的。转置一下,X@W.T就变成“每一行是样本,每一列是神经元”的相似度矩阵,输出里第i行第j列就是第i个样本对第j个神经元的原始激活值。
这个操作把两层循环(遍历样本、遍历神经元)压缩成一次矩阵乘法。我刚开始学的时候很疑惑:为什么框架里能直接写nn.Linear(in_features, out_features),底层不就是一个矩阵乘加偏置吗?后来才意识到,矩阵乘法的价值就是把“大量重复的点积”集中起来并行执行。CPU 和 GPU 底层的 BLAS 库对矩阵乘法做了极致的优化:分块、内存缓存、SIMD 指令,甚至多核并行。手动写for sample in batch: for neuron in weight:做点积,速度连@的百分之一都不到。
3.3 矩阵运算中的批量与维度扩展
模型训练时通常一次喂一个 batch,而不是一个样本。假设 batch size 是 64,输入特征数是 128,输出神经元数是 256,那么X是(64, 128),W是(256, 128),输出Z是(64, 256)。一次@把 64×256 个点积全部算完,这才是真正的“批量计算”。
有时候你还会需要把多个权重矩阵堆起来,那就需要三维张量。举个例子,多头注意力里每个头都有自己的W_q、W_k、W_v,头数为8,每个头的输入维度是d_k。你当然可以 for 循环8次,但更高效的做法是把8个权重矩阵叠成一个形状(8, input_dim, d_k)的三维张量,然后一次torch.matmul完成多头的线性变换。这就是张量运算延展矩阵乘法的经典场景。
4. 张量运算:广播机制、维度变换与内存布局
4.1 张量是带批次的矩阵:图片、文本和序列的高维真相
很多初学者看到四维张量就头晕,其实只要记住“前面的维度是批量相关,后面的维度是单样本内部结构”就够用了。
图像分类中,一批 RGB 图片的形状是(batch_size, channels, height, width),比如(16, 3, 224, 224)。16是图片张数,3是RGB通道,后面是每张图片的高宽。如果你把单张图片(3, 224, 224)当成“一张特殊的矩阵”,那(16, 3, 224, 224)就是16个这样的矩阵叠在一起,所以叫张量。
自然语言处理中,一句话经过词嵌入后得到(seq_len, hidden_size),一批句子则是(batch_size, seq_len, hidden_size)。注意力分数矩阵是(batch_size, num_heads, seq_len_q, seq_len_k),简直是一个套娃。但只要你给每个维度起了名字,就不会乱。
4.2 广播机制:没有复制数据,却像把数据扩展了一样
两个张量做逐元素运算时,形状不一定要完全一样。NumPy 和 PyTorch 遵循一套广播规则:从右往左对齐维度,每个维度要么相等,要么其中一个等于1,要么一个维度缺失。看这个例子:
import torch a = torch.randn(3, 1) # 形状 (3, 1) b = torch.randn(1, 4) # 形状 (1, 4) c = a + b # 结果形状 (3, 4)这里是(3, 1)和(1, 4)相加,广播后相当于a的第1维复制4份,b的第0维复制3份,最终得到(3, 4)。
广播看起来很方便,但也埋了很多雷。有一次我调试一个模型,发现某个中间结果在 batch size 大于1时出现奇怪的低精度,排查半天才发现是形状(batch, 1)的向量和一个(batch, feature)的矩阵相加,广播把向量“横向拉伸”到了所有特征维上,导致每个样本都加了同样的偏置。这不是错误,但完全不是我想要的。所以遇到形状警告或者奇怪的数值,第一反应就是检查是不是广播在偷偷“帮倒忙”。
4.3 维度变换是改“视图”而不是改“数据”
view、reshape、permute、transpose是张量维度操作的四件套,但它们的底层逻辑完全不同。
permute/transpose只交换维度顺序,不改变底层数据的排列顺序。view/reshape则要求两个视图的元素顺序一致,如果之前做过transpose,底层内存顺序已经不是原来的顺序,直接view会报错或者产生错误结果。- 解决办法是先
contiguous(),让张量在内存里变得连续,再view。
我见过很多同学在attention实现里把(batch, heads, seq_len, d_head)转成(batch, seq_len, heads * d_head)时,直接view报错,然后一脸懵。原因就是之前做过transpose。正确的做法是permute之后.contiguous().view(...)。
理解内存布局还有性能意义。GPU 对连续内存的访问效率远高于跳跃式访问。如果你把NCHW的图片张量transpose成NHWC,虽然形状看着没问题,但每次内存读取都不连续,训练速度可能下降百分之二三十。所以尽量让数据从加载到计算都保持同一套布局,别在模型中间来回转置。
5. 手写一个线性层:用 NumPy 复现前向传播和反向梯度
5.1 前向传播:一行矩阵乘法就够
我们用一个小例子把前面所有知识串起来。假设输入X有3个样本,每个样本4个特征;线性层有2个神经元:
import numpy as np X = np.array([ [1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0], [9.0, 10.0, 11.0, 12.0], ]) # 形状 (3, 4) W = np.array([ [0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8], ]) # 形状 (2, 4),一行是一个神经元 b = np.array([0.1, 0.2]) # 形状 (2,) Z = X @ W.T + b # (3, 2) print(Z)X @ W.T的形状是(3, 2),加上b时广播到每一行。这个Z就是两个神经元对三个样本的原始输出。如果后面接一个sigmoid,它就是2个神经元的前向激活值。
很多人会疑惑,为什么不把W定义成(input_dim, output_dim),直接用X @ W?当然也可以,但 PyTorch 的nn.Linear官方实现里权重形状是(out_features, in_features),所以需要转置。我这里故意用同样的定义方式,就是为了让你在看框架文档时能对上号。
5.2 反向传播:梯度也是矩阵乘法
训练模型时反向传播算梯度,本质上还是在做矩阵乘法。对于线性层Z = X @ W.T + b,如果我们得到了损失对Z的梯度dZ,那么权重的梯度就是dW = dZ.T @ X。用刚才的数据试一下:
dZ = np.array([ [1.0, -1.0], [0.5, 0.5], [-0.5, 0.2], ]) # 模拟上游梯度 dW = dZ.T @ X # 形状 (2, 4) print(dW)这里dZ.T形状是(2, 3),X形状是(3, 4),结果恰好是(2, 4),和W形状一致。如果写成X.T @ dZ也行,关键是矩阵乘法要让我们把“每个神经元对每个输入特征的梯度”都算出来。
自己用 NumPy 写一遍这个流程,比看十遍原理都管用。做完之后你会特别直观地理解为什么 autograd 能自动求梯度:它只是在计算图的每个节点上应用了类似的矩阵乘法规则而已。
5.3 加上激活函数:非线性藏在逐元素操作里
线性层串再多矩阵乘法,所有层加起来仍然是一个线性变换,因为矩阵乘法满足结合律。真正让深度学习变“深”的是激活函数:ReLU、Sigmoid、Tanh 等都是逐元素的张量运算。
用代码体现一下:
def relu(tensor): return np.maximum(tensor, 0) A1 = relu(X @ W1.T + b1) A2 = relu(A1 @ W2.T + b2)这里A1是(3, hidden_dim)的矩阵,它同时是下一层的输入。所以张量的形状就是在这样的逐层变换中流转。每个 batch 的数据从(batch, input_dim)变成(batch, hidden_dim),再变成(batch, output_dim),整个模型就是一系列矩阵乘法和逐元素激活函数的组合。
6. 我踩过的张量形状坑:维度错位、广播误用和性能瓶颈
6.1 形状错误:“忘记转置”是头号坑
我自己刚写 Transformer 时,卡得最久的就是注意力分数计算。Q和K的形状都是(batch, heads, seq_len, head_dim),按理说要对最后两个维度做点积:Q @ K.transpose(-2, -1),结果是(batch, heads, seq_len, seq_len),同时转置的位置还关系到K要不要先contiguous。
有次我把K转置后直接view,报错说“view size is not compatible with input size”。后来才意识到transpose会让内存布局变成非连续,必须先.contiguous()。这个坑几乎人人都会踩一次,解决办法就是记住:transpose和permute之后,如果要改变形状或者接入某些要求连续内存的操作,先加contiguous()。
6.2 广播误用:相似度矩阵被悄悄“降维”
另一个经典问题是计算两个矩阵两两之间的相似度。假设A是(m, d),B是(n, d),你想要(m, n)的相似度矩阵。最自然的写法是两层循环:
for i in range(m): for j in range(n): sim[i, j] = np.dot(A[i], B[j])但这样既冗长又慢。高效写法是:
sim = A @ B.T # 形状 (m, n)直接把所有点积一次算完。这个例子说明,很多“看起来要循环”的场景其实都能用矩阵乘法表达。学会用@替代显式循环,不仅代码整洁,速度优势尤其明显。在一组 512 个 query 和 512 个 key 上对比测试,循环要几毫秒,矩阵乘法可以到微秒级,而且批量越大差距越明显。
6.3 精度和内存:隐藏的性能杀手
我遇过一种情况:模型在 PyTorch 上是float32,但中间为了调试把数据转成 NumPy,某些操作默认变成了float64,再转回 Tensor 时显存直接翻倍。在batch size很大的场景下,这一下就可能让 OOM 出现。
解决办法是时刻关注dtype。NumPy 创建数组时默认float64,如果你只是做数值验证,没关系;但如果你在模型训练循环里做自定义数据处理,记得显式指定dtype=np.float32,或者用torch.float32。
另外,GPU 上减少不必要的张量复制也很重要。大多数框架的view和reshape不会复制数据,但contiguous()在某些情况下会触发新拷贝,频繁调用会拖慢整体速度。我在调优时常用的一个原则是:从一个张量到另一个张量,尽量只改变每个维度的含义,而不是反复交换维度顺序。如果非要换,就把所有转置集中到数据预处理阶段,不要在模型主循环里做。
最后再补充一个很多人忽略的点:不要迷信einsum。它确实能让复杂的高维矩阵乘法写得很简洁,比如注意力里的torch.einsum('bqhd,bkhd->bhqk', Q, K)。但einsum的缺点是报错信息非常隐晦,内部还可能走通用路径,性能未必优于显式的@加transpose。我的习惯是先用显式矩阵乘法调通功能,确认需求后再考虑用einsum简化代码。这样踩的坑最少,效率也最高。
机器学习这条路走到越深,越会发现数学不是“纸上公式”,而是嵌在每一行代码里的思维框架。向量、矩阵、张量的运算规则就是AI的底层语法,搞懂了它们,你看模型源码会顺畅很多,排查 bug 也不用靠瞎猜。希望这篇“计算语法”笔记能帮你把线性代数和实际模型的最后一段缝隙也补上。