写这个系列第三篇的时候,我正好在带一个新来的实习生调一个分类模型的训练脚本。他卡在一个看起来很基础的地方:loss.backward()一直报维度不匹配,查了半天发现是自己在构造标签的时候把[batch]的张量写成了[batch, 1],然后广播机制又把整个事情变得不可预测。事后我跟他聊了很久,发现他对标量、向量、矩阵、张量这四者的运算规则其实没有一个系统的框架,全靠报错驱动学习。
这恰好也是我想在这一篇里集中解决的问题。前两篇我们聊了标量、向量、矩阵的基本定义和它们之间的区别,这篇我打算把重心放到“运算”和“工程落地”上:为什么向量的点积结果是标量?矩阵乘法为什么是这样定义的?在实际写深度学习代码时,张量的维度变化到底该怎么想才不会乱?同时结合几个常见的实际应用场景——混淆矩阵、向量数据库、Embedding 模型——把这些数学概念从课本里拽出来,落到你真正会写的那几行代码上。
1. 从标量到张量:维度到底是怎么一步步长出来的
1.1 先给“维度”一个不啰嗦的框架
很多人学到这里就乱了,根源在于把“维度”这个词在不同语境下混着用。在数学和深度学习的框架里,一个数据有几个“轴”,它就说是几维的,这个维度不是你感知上的空间维度,而是“索引一个数据元素需要几个坐标”。
标量是 0 维的,就是一个单独的数,比如温度 36.5,你不需要任何坐标就能拿到这个值。向量是 1 维的,比如班级里五个人身高组成的数串,你只需要一个下标就能指出第几个人的身高。矩阵是 2 维的,比如五个人三科成绩的成绩表,你需要“第几个人”和“第几科”两个坐标才能锁定一个分数。张量就是把这个思路继续往外推,3 维、4 维、5 维……每一维就是一个“索引轴”。
我给学生讲的时候特别喜欢用快递柜打比方。标量就是你手上的一件快递,向量是一排柜子,你报一个柜号就能取。矩阵是一个货架,你需要“第几排第几列”才能找到。张量就是整个仓库,可能需要“几号楼、几层、几排、几列”才能精确定位。深度学习里的数据基本上都是“整个仓库”级别的。
1.2 深度学习为什么一定要用张量
如果你训练过图像模型就一定见过这种形状:[batch_size, channels, height, width],这就是一个标准的 4 维张量。batch_size是一次同时喂进去多少张图,channels是颜色通道,RGB 就是 3,灰度就是 1,后面两个是图片的高和宽。你很难用矩阵去表示这种数据,因为矩阵只有两个轴,装不下批量和通道这两个信息。张量就是为此而生的。
自然语言处理里更典型,一个 batch 的文本经过 Embedding 之后通常是[batch_size, seq_len, hidden_size]。seq_len是句子长度,hidden_size是每个词映射到的向量维度。这三个轴分别表示“哪一句话”“句子里的第几个词”“这个词用多少维的向量表示”。你要是只学过矩阵,面对这种 3 维数据大概率会懵,因为你不知道哪两个轴可以合并、怎么合并才不会丢信息。
这也是为什么现在的深度学习框架把所有数据都叫 Tensor。PyTorch 里一切的输入输出都是torch.Tensor,NumPy 里叫ndarray,本质都是多维数组。理解了张量就是“带任意多个轴的数组容器”,后面所有的shape、reshape、transpose才有讨论的落脚点。
1.3 轴顺序的约定是个大坑
张量有了多个轴之后,轴的先后顺序就不是小事了。同一个数据用[N, C, H, W]和[N, H, W, C]存,计算机内存里的排布逻辑是完全不同的。图片的像素点在内存中按行存储,如果你想用[N, H, W, C]的顺序,每个像素的 RGB 三个值是连续排在一起的;但如果你用[N, C, H, W],那就是所有 R 通道先连成一片,然后是所有 G 通道,最后才是 B 通道。
PyTorch 默认用[N, C, H, W],TensorFlow 早期默认用[N, H, W, C],这导致同一个模型在两套框架之间迁移时,经常需要permute或者transpose来调整轴顺序。我自己就曾经把一个用 PyTorch 训练好的图像预处理流程直接搬到一个 TensorFlow 推理服务里,结果颜色通道对不上,模型输出的准确率掉了一大截。原因就是通道轴位置变了,模型看到的“红绿蓝”和训练时不一致。这种问题报错信息很弱,甚至不报错,它只会在效果上折磨你。
所以在写代码之前,一定要先确认你手里的数据是什么 shape,目标 API 期望什么 shape,然后显式地做转换。不要依赖框架给你偷偷调整,因为它们大多数时候不会调。
2. 向量乘法深度拆解:为什么向量的乘积会是标量
2.1 点积的两种定义视角
向量点积是深度学习里出现频率最高的运算之一,公式看起来很简单:两个等长向量对应位置元素相乘再求和。例如[1, 2, 3]和[4, 5, 6]的点积就是1×4 + 2×5 + 3×6 = 32。结果是一个数,也就是标量,所以很多人会直接问“为什么向量的乘积是标量”。
严格说不是所有向量乘积都是标量。两个向量之间至少有两种乘法:点积产出标量,叉积产出向量。点积之所以叫“点积”,因为它结果的维度比原来少了一维;叉积在三维空间中产出一个垂直于原来两个向量张成平面的新向量,它还保留着方向。
从几何上看,点积的另一种定义是a · b = |a||b|cosθ,其中 θ 是两个向量的夹角。这个定义更能解释为什么结果是标量:它刻画的是“一个向量在另一个向量方向上的投影长度”乘上“另一个向量的长度”。投影是一个数,长度是一个数,乘起来当然还是一个数。夹角为 0 时两个向量方向完全一致,点积取最大值;夹角为 90 度时方向垂直,点积为 0。这个“点积为 0 意味着垂直”的性质在机器学习里特别常用,比如正交基、去相关、注意力分数计算。
2.2 神经网络里的点积到底在算什么
深度学习里最密集使用点积的地方就是线性层。一个没有偏置的线性变换可以写成y = W^T x,这里的乘法的核心就是逐行计算权重和输入的点积。全连接层的每个输出神经元做的事情可以理解为:把输入向量和该神经元对应的权重向量做点积,得到一个标量,然后可选地加偏置、过激活函数。
Transformer 里的自注意力机制更是把点积用到了极致。Q和K的每一行都是向量,用Q·K^T计算注意力分数,本质上就是在算每一对 token 的向量点积。点积大说明方向相近,也就是语义上更相关;点积小则说明不太相关。你想一下,这跟余弦相似度本质上是同一件事,只是差一个归一化常数。理解到这一层,你就不会再把注意力机制当成什么神秘的魔法了。
关于范数也值得多说一句,因为热词里反复出现“向量范数”。范数就是衡量向量“长度”的一种方式。L1 范数是各元素绝对值之和,L2 范数是各元素平方和再开根号。L2 范数在正则化里极其常见,权重衰减惩罚的就是模型参数的 L2 范数平方。L1 范数则因为它在零点不可导的特殊性质,能带来稀疏解,常常用于特征选择。为什么同一个“长度”有不同的算法?因为你要的“长度”语义不同。L2 强调整体大小,L1 强调分量总和,这就好比衡量个人的“工作量”,你可以用总时长,也可以用任务件数,两者都不是错的,只看你要哪个维度。
2.3 一个特别容易踩的数学坑:维度匹配
点积要求两个向量长度相等。在代码里这意味着[3]和[3]可以直接dot,但[3]和[4]就会报错。很多人刚开始写注意力代码时,经常遇到query向量的维度是[batch, seq, head_dim],key的维度是[batch, seq, head_dim],看起来一样,但中间多了两层矩阵乘法后,某一个轴的顺序悄悄换了,结果怎么都对不上。
我的建议是:在关键矩阵乘法前后各加一条打印shape的语句,把每一维都打出来。笔试考试你可以靠心算维度,但真正调模型的现场,打印是最快的定位方式。等代码稳定后再把打印删掉或者放到一个DEBUG开关后面,不要觉得打印很丢人,调试是深度学习从业者的基本生存技能。
3. 矩阵乘法:不要死记规则,要理解“变换的复合”
3.1 矩阵乘法那条古怪规则的由来
刚开始学矩阵乘法的时候,几乎所有教材都会教“左行右列”法:结果矩阵第 i 行第 j 列的元素,等于左边矩阵第 i 行和右边矩阵第 j 列的点积。很多人背下了这个规则,但完全不知道为什么。这里我建议换个角度:矩阵不是数字表格,矩阵是“一种动作”。
如果你把矩阵理解为对向量的操作,那么Ax的意思就是“把向量 x 通过矩阵 A 做一个线性变换”。两个矩阵相乘AB的含义就变成了“先做 B 变换,再做 A 变换”。这跟函数复合f(g(x))是一个道理。这个视角解释了矩阵乘法为什么不像逐元素乘法那样直观,也解释了为什么AB通常不等于BA:你先旋转再缩放,和先缩放再旋转,结果很可能不一样。
这就好比你先穿袜子再穿鞋,和先穿鞋再穿袜子,是完全不同的体验。矩阵乘法的顺序本身就是变换的顺序,不能随意交换,这是理解整个线性代数的钥匙。具备这个视角之后,你再看卷积神经网络里的卷积核、全连接层的权重矩阵、循环神经网络里的状态转移矩阵,你看到的就不再是一堆数字,而是一系列叠加在数据上的动作。
3.2 几类特殊矩阵在深度学习里的真正位置
热词里提到了对称矩阵、初等矩阵、正交矩阵、对角矩阵,这些在深度学习里不是考知识点,而是真实工具。
对称矩阵满足A^T = A,它的特征值都是实数、特征向量可以取正交。协方差矩阵就是对称矩阵,PCA 主成分分析本质上就是在对协方差矩阵做特征分解。你在做数据白化、特征去相关时看到它,不要觉得只是一个数学概念。
对角矩阵除了对角线上有值其他位置全为 0,它做的事情是“把每个分量独立缩放”。批量归一化层在推理时对每个通道的缩放,如果忽略平移项,本质上就是一个对角变换。因为对角矩阵不涉及不同维度的混合,它是最简单、最容易被人类解释的一类线性变换。
正交矩阵满足A^T A = I,它保持向量的长度和夹角不变,所以它是旋转的矩阵表示。在深度学习中,权重初始化时常常用到正交初始化,就是希望初始的线性变换不至于一开始就扭曲信息的距离结构。正交矩阵不会放大梯度,这对深层网络的训练非常友好。
初等矩阵则是通过对单位矩阵做一次初等行变换得到,这类矩阵是高斯消元的基石。虽然直接写模型的人很少会去构造初等矩阵,但理解它有助于你理解矩阵求逆、行列式和线性方程组的求解逻辑。很多调试场景里你需要手推一个小例子去验证算法,这时初等变换的手感都是很重要的基本功。
3.3 线性层就是矩阵乘法,不要神化它
深度学习里最常见的nn.Linear(in_features, out_features)本质上做的是y = xW^T + b。这里W的形状是[out_features, in_features],x的形状是[batch, in_features]。矩阵乘法之后,你最后得到的形状是[batch, out_features]。为什么 W 要转置?这是 PyTorch 的约定,nn.Linear内部存的是[out_features, in_features]形状的权重,计算时对输入做x @ W.T,原因在于它想让你定义的每个神经元对应一行权重,这样写代码的时候直觉比较顺。
我见过不少新手在这个地方卡壳,明明定义好了模型,forward 里一算就是维度爆炸。如果你理解了矩阵乘法维度匹配原则——[m, n]乘以[n, p]得到[m, p]——你会发现所有线性层的 shape 推算都可以在 30 秒内手算完成。写代码前花半分钟把权重矩阵的 shape 列出来,远比在 GPU 上报错后瞎猜高效得多。先算后写,永远是矩阵相关代码的第一准则。
4. 深度学习框架里的张量实操:shape 世界的生存法则
4.1 reshape、view、transpose、permute 的区别
这一节是给准备写 PyTorch 代码的人看的,但理解这些思想,换到 TensorFlow、JAX 同样适用。
reshape和view都用来改变张量形状,但不完全一样。view要求在内存中数据是连续存储的,因为它只是改变了“如何解读”这段内存,不搬运数据。reshape更宽容,如果数据不连续,它会自动在背后拷贝出一份连续的数据再变形。所以如果你只是调整维度,优先用view;如果要从transpose的结果继续变形,或者做了切片之后要变形,直接用reshape更安全。强行对不连续张量用view会报错,这类错误信息看多了你就知道什么叫“内存布局决定你能做什么”。
transpose是交换两个轴,permute是任意重排所有轴。比如一个[batch, seq, hidden]的张量要变成[batch, hidden, seq],可以用transpose(1, 2),也可以permute(0, 2, 1)。后者更通用。要注意的是,transpose 之后张量通常变成非连续内存,再想 view 就会出问题,必须先contiguous(),这一点几乎踩遍每一个新手。
4.2 广播机制:把维度自动“补齐”的魔法与陷阱
NumPy 和 PyTorch 都有广播机制,它允许不同形状的张量做逐元素运算时自动扩展。基本原则是:从最后一个轴开始比较,如果两个维度相等,或者其中一个为 1,或者其中一个缺失,那么这个维度就可以广播;否则报错。
举例来说,[3, 1]和[1, 4]相加,结果是[3, 4]。第一个张量在第二个维度上相当于被复制了 4 次,第二个张量在第一个维度上被复制了 3 次。这是非常高效的操作,因为你并没有真的创建那一堆重复数据,内存里只保留原始数据。
但广播机制也是隐性 bug 的重灾区。回到开头那个实习生的例子:标签[batch, 1]和模型的输出[batch, num_classes]计算损失时,可能会因为在某个维度上自动广播而产生一个你以为正确、但含义完全错误的结果。这种错误通常不报错,只在验证集指标上露出马脚。所以我一般建议:所有跟逐元素运算相关的张量,尽量在构造时就显式给出完整 shape,不要依赖广播去“顺手”补维度;如果确实依赖广播,务必在关键节点打印结果 shape 确认。
4.3 从零开始手推一个张量的维度变化
我拿一个常见的文本分类模型来举例,帮你建立“手推维度”的习惯。
输入是一个 batch 的句子,先转成 token id,形状是[batch=32, seq_len=128]。经过nn.Embedding(vocab_size, hidden_size=768)之后变成[32, 128, 768]。如果你后面接的是 LSTM,LSTM 默认接收[seq_len, batch, hidden]的输入,所以你需要把前两个轴交换一下,也就是x.transpose(0, 1),得到[128, 32, 768]。LSTM 的输出还是[seq_len, batch, hidden],你再把它transpose(0, 1)回到[32, 128, 768],然后取最后一个时间步的输出或者做全局池化,得到一个[32, 768]的向量。最后接一个nn.Linear(768, num_classes=10),输出[32, 10],跟标签[32]做交叉熵。
整个链条里每个节点你都能用笔写出 shape,那么任何一步报错你都能迅速定位。不夸张地说,深度学习的代码调试,有一半的功夫是在调 shape。谁能心算出每一步的 shape,谁就能在报错面前保持冷静,而不是瞎试。
5. 数学工具的真实战场:混淆矩阵、向量数据库与 Embedding
5.1 混淆矩阵:分错与分对全写在一张表里
混淆矩阵是整个深度学习评估体系里的一个经典矩阵应用,尤其多分类任务几乎天天用。它的大小是[类别数, 类别数],第 i 行第 j 列表示“真实类别是 i,但模型预测成 j”的样本数量。对角线上的值就是预测正确的数量,非对角线都是错误。
用 PyTorch 或 NumPy 自己写混淆矩阵非常快,核心思路就是按真实标签和预测标签两两配对计数。如果用了 sklearn,一行confusion_matrix(y_true, y_pred)就出来了。但如果你理解了它的定义,即便没有 sklearn 也能在 10 行以内实现,这对理解 precision、recall、F1 这些指标特别有帮助。
precision 是从预测角度看的:预测为正例的样本里,有多少是真正的正例;recall 是从真实角度看的:真实的正例样本里,有多少被找了出来。这两者常常互斥,所以有了 F1 这个调和平均。在一个类别极不平衡的数据集里,只看 accuracy 会被“全预测成多数类”的假象欺骗,但混淆矩阵可以让你一眼看到少数类被分成了什么样。我看到很多新手在评估模型时只看一个 loss 和一个准确率,这远远不够。至少打一次混淆矩阵,把所有类别的分布都看清楚,再决定下一步是加数据还是调阈值。
5.2 向量数据库:把点积和余弦相似度变成工程基础设施
热词里“向量数据库”出现频率非常高,这里我要多说几句,因为它可能是“向量”这个概念离工程场景最近的一次。
Embedding 模型会把一句话、一张图、一个实体编码成一个向量。这些向量通常在几百到几千维之间,可以理解成实体在高维空间里的坐标。语义相近的内容,它们向量的夹角小、点积大、余弦相似度高;语义无关的内容,夹角大、相似度低。向量数据库做的事情就是:维护海量实体向量,并提供“给定一个查询向量,快速找出与其最近的前 K 个向量”的能力。
这里面的“近”,可以是欧氏距离、点积、余弦相似度,具体用哪一个由你选择的索引类型决定。比如 Faiss 里的IndexFlatIP用内积,IndexFlatL2用欧氏距离。Milvus 和 pgvector 这类系统则是把这个能力交付成可水平扩展的数据库服务。
回答一个被反复问的问题:AI 智能体的企业知识库是不是存放在向量数据库里?答案是:知识库的原文通常还是存在传统数据库或对象存储里,但知识的“语义索引”存在向量数据库里。流程是:把文档切成 chunk,每个 chunk 用 embedding 模型转成向量,存入向量数据库;用户提问时,同样把问题转成向量,去向量库里检索最相关的 chunk,再把检索结果组装成上下文交给大模型生成回答。没有向量数据库,这个按语义检索的过程就得写暴力线性扫描,数据量一上来就慢得没法用。
5.3 本地部署向量模型:一个极简的实操参考
热词里有人问“mac 怎么本地部署向量模型”,这可能是指在本机跑一个 embedding 模型。以text2vec-large-chinese或者BAAI/bge-large-zh-v1.5为例,如果你有 GPU 或一个足够大的内存,用sentence-transformers库加载模型其实非常简单。
先pip install sentence-transformers,然后:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-large-zh-v1.5") sentence = "我今天去了西湖边散步" embedding = model.encode(sentence) print(embedding.shape)输出通常是[1024]或者[768]这样的向量。你把一批句子的向量都算出来,然后用 Faiss 建索引、查询,这就是一个最简的本地语义检索服务了。Mac 上跑这类模型完全可行,只是速度取决于你的硬件;CPU 推理不会特别快,但做演示和小规模验证没有压力。
这里有个实战提醒:不同 embedding 模型输出的向量维度可能不一样,在使用向量数据库时,同一个集合里的向量必须来自同一个模型,否则算出来的相似度没有任何意义。另外,很多模型在encode前会自带文本预处理,比如加 prompt、处理最大长度,不同版本的库行为可能略有不同,上线前一定要用几个已知语义关系的句子做一遍冒烟测试。
6. 实操避坑:维度错误、广播陷阱与调试经验
6.1 高频报错与排查速查表
深度学习框架的报错信息虽然各不相同,但高发问题其实就那么几类。我把这几年遇到最多的整理成一张表,方便你遇到问题时快速对照。
| 报错现象 | 常见原因 | 解决思路 |
|---|---|---|
| Matrix multiplication shape mismatch | 矩阵乘法内维不相等 | 打印两个矩阵的 shape,按[m,n]×[n,p]规则检查 |
| Expected 2D tensor but got 3D | 把 3 维张量直接传给只接受 2 维的层 | 先 reshape 或 permute,把 batch 维和其他维合并 |
| view size not compatible with tensor size | 变形后元素总数不一致,或对非连续张量用 view | 检查元素总数,必要时先 contiguous() |
| Broadcast shape mismatch | 两个张量某些维度既不相等也不为 1 | 打印两个 shape,手动对齐最后一个维度看 |
| CUDA error: device-side assert triggered | 标签值超出类别范围 | 检查标签是否从 0 开始、是否包含 -1 等非法值 |
| RuntimeError: expected scalar type Float but found Long | 数值类型不一致 | 对张量调用.float()或检查数据加载时是否转成了对应 dtype |
这张表并不能覆盖所有情况,但它能帮你节省大量时间。我自己的习惯是:看到一个报错不要马上改代码,先花一分钟重复“打印 shape、打印 dtype、打印数值范围”这三部曲。绝大多数维度类错误在打印后几秒钟内就能定位。
6.2 一个真实案例:手写多头注意力时的维度混乱
我去年给团队写一个轻量级 Transformer 模块,自己也在多头注意力上踩过一次很低级的坑,写出来给大家当反面教材。
我当时定义num_heads=8,head_dim=64,输入x的形状是[batch=16, seq=50, hidden=512]。我需要把输入拆成 8 个头,每个头拿[16, 50, 64]。直观的想法是x.view(16, 50, 8, 64),这一步没问题。但接着我想把头的维度放到前面,写了x.permute(0, 2, 1, 3),此时形状是[16, 8, 50, 64],也正确。然后我为了把 batch 和 head 合并起来计算注意力,直接用了view(16 * 8, 50, 64)。
这里就出事了。因为permute之后内存已经不连续,直接view报错。我当时很自信地觉得“数据形状一样,view 应该没问题”,被报错提醒后才想起contiguous()的事。改成x.permute(0, 2, 1, 3).contiguous().view(16 * 8, 50, 64)才通过。
这个错误很小,但它说明了一个道理:写代码时不能只看逻辑形状,还要留意内存布局。而且这种错误在新手身上几乎每天都会发生,因为它不涉及高深数学,纯粹是框架底层机制的问题。建议你使用view前养成一个条件反射:如果这个张量刚做过 transpose、permute、切片、拼接这类操作,先.contiguous()或者直接用reshape。
6.3 数值稳定性与矩阵运算溢出
还有一个容易被忽略的问题是数值溢出。深度学习里很多损失函数和激活函数都涉及指数运算,比如 softmax、交叉熵、注意力分数缩放。如果分数值本身很大,exp很容易溢出成inf,导致 loss 变成 NaN。
解决方法通常是“减去最大值”这个经典技巧:softmax(x) = exp(x - max(x)) / sum(exp(x - max(x)))。数学上这个变换不改变结果,但数值上能把指数函数的输入范围拉到(-inf, 0],从而避免溢出。PyTorch 的CrossEntropyLoss内部已经做了这类稳定化处理,所以大多数情况下你不用担心,但如果你自己实现损失函数或注意力模块,就一定要记得这件事。
矩阵乘法本身也有累积误差问题。在训练初期数值量级正常时问题不大,但是当模型设计不合理导致中间激活值过大,矩阵乘法的结果就会膨胀,随后的归一化层或者 softmax 就可能因为巨大的输入而饱和或溢出。遇到这种情况,优先检查网络中间层的输出数值分布是否合理,而不是急着调学习率。一个简单的做法是在 forward 的若干关键节点插入tensor.abs().mean()的打印,看看量级是不是在以指数级放大。这种排查方式看起来很土,但在很多实际问题里比任何花哨工具都管用。
6.4 写代码前先手推维度:一份个人小习惯
说了这么多避坑经验,最后分享一个我一直坚持的习惯:凡是涉及矩阵乘法和张量变形的核心模块,写代码之前先在注释里写下 shape 变换链。比如我写一个 self-attention 的 forward,开头大概是这样的:
# x: [batch, seq_len, hidden] # q, k, v: [batch, seq_len, hidden] -> view -> [batch, seq_len, heads, head_dim] # -> permute -> [batch, heads, seq_len, head_dim] # scores: q @ k.T -> [batch, heads, seq_len, seq_len] # attn: softmax(scores / sqrt(head_dim)) # out: attn @ v -> [batch, heads, seq_len, head_dim] # -> permute -> [batch, seq_len, heads, head_dim] # -> view -> [batch, seq_len, hidden]这几行注释看起来不起眼,但它能让你在写代码的时候保持清醒,每一步都清楚自己在做什么。很多新手写代码是“边写边猜”,遇到报错再回头修,这个模式在简单项目里能撑过去,但一旦模块复杂起来,猜的成本会高到可怕。
手推维度不是竞赛技巧,就是一种工程素养。真正到了大模型时代,输入动辄几百亿参数,任何一点 shape 理解不到位都会被放大成灾难性的训练失败。从最简单的标量、向量、矩阵、张量开始,把这些基本功打扎实,才是所有后续进阶最可靠的底座。