矩阵乘法
矩阵乘法得到的是一张由点积组成的表:C = AB 的元素 C[i][j],等于 A 的第 i 行与 B 的第 j 列的点积。其余性质都由这条规则推出:只有 A 的行和 B 的列长度相同,乘积才存在;(m×n)(n×p) 需要 m·n·p 次乘法;BA 通常是另一张表。
第一次见到"行乘列",它像是随手定下的约定。其实不是。A 的一行是一个输入,B 的一列是一组权重,两者的点积就是上一课的那个分数。矩阵乘法只是把这些分数一次算完:每个输入对每组权重都算一遍,并且把第 i 行和第 j 列的结果放在格子 (i, j) 里。这样读,形状规则就不用死记:点积需要两个等长的列表,形状规则说的就是这一点。
矩阵乘法的原理
把 A 放在左边,B 放在结果位置的上方。这样,A 的第 i 行横着正好通向 C 的第 i 行,B 的第 j 列竖着正好通向 C 的第 j 列。行与列交叉的格子就是 C[i][j],它的值是两者的点积:同时沿着这一行和这一列走,把第 k 个数两两相乘,再相加。
$$C_{ij} = \sum_{k=0}^{n-1} A_{ik},B_{kj}$$
由此得到三个事实:
- 内侧维度必须相等。 行里的数有 A 的列数那么多(n 个),列里的数有 B 的行数那么多。两个列表要逐项配对,所以长度必须一样。不一样时,总有一个数找不到搭档,乘积就不存在。
- 外侧维度决定形状。 C 的行数等于 A 的行数,列数等于 B 的列数:(m×n)(n×p) 得到 m×p。共同的 n 在求和里消失了。
- 顺序有影响。 BA 是把 B 的行和 A 的列配对,用到的是完全不同的列表。它的形状可能不同,也可能根本不存在;即使存在且形状相同,数值通常也不同。AB = BA 是例外,不是规律,只在特殊情况下成立,比如 B = I(单位矩阵)或 B = A。
在神经网络里,这幅图里的东西都有名字。把一批输入作为 X 的各行,把一层的各个权重向量作为 W 的各列,那么 XW 就装着每个输入在每个权重向量下的分数,每个格子一次点积。这就是线性层在加偏置和激活函数之前做的计算。
矩阵乘法逐步演示
默认输入是 A (2×3) = [[1, 2, 3], [4, 0, 1]],B (3×2) = [[2, 1], [1, 0], [3, 2]]。下标从 0 开始,和 NumPy、PyTorch 一致。
- 检查形状。 A 的每行有 3 个数,B 的每列也有 3 个数,可以配对:3 = 3。外侧维度给出 C 的形状 2×2,A 的行与 B 的列交叉处出现一张空的 2×2 表。
- 对齐第一个格子。 C[0][0] 需要 A 的第 0 行 (1, 2, 3) 和 B 的第 0 列 (2, 1, 3)。两者的副本飞进工作台,逐项配对。
- 逐项相乘。 1 × 2 = 2,累计 2。然后 2 × 1 = 2,累计 4。再 3 × 3 = 9,累计 13。乘法计数器显示 3 / 12。
- 写入格子。 累计和落进 C[0][0] = 13。一个格子,一次点积。
- 扫完 C 的其余格子。 同一条规则按行的顺序填满剩下的格子。C[0][1] 还是第 0 行,换到第 1 列:1×1 + 2×0 + 3×2 = 7。C[1][0] = 4×2 + 0×1 + 1×3 = 11。C[1][1] = 4×1 + 0×0 + 1×2 = 6。A 里的 0 照样要花一次乘法。
- 读出乘积。 AB = [[13, 7], [11, 6]]:4 个格子 × 3 项 = 12 次乘法。
- 交换顺序。 A 和 B 交换位置:B 移到左边,A 移到上方,算好的 AB 停到一旁。现在 B 的行(2 个数)对上 A 的列(2 个数):2 = 2,所以 BA 存在,而且是 3×3。
- 填满 BA。 九个格子,每个都是长度为 2 的点积:BA = [[6, 4, 7], [1, 2, 3], [11, 6, 11]],共 18 次乘法。
- 比较。 AB 是 2×2,12 次乘法;BA 是 3×3,18 次乘法。同样两个矩阵,换了顺序,就是另一张表。AB ≠ BA。
在画布上,留意那两条描了边的带子:一条横穿左侧矩阵的一行,一条竖穿上方矩阵的一列。两条虚线引导线从带子延伸出去,汇合的格子就是正在计算的格子。舞台上方的计数器每算一个格子就加 n,直到 m·n·p。
时间与空间复杂度
| 量 | 代价 |
|---|---|
| 乘法 | m·n·p |
| 加法 | m·p·(n − 1) |
| 输出空间 | m·p 个元素 |
| n×n 方阵 | O(n³) 时间,O(n²) 空间 |
教科书算法的时间复杂度是 O(m·n·p):m·p 个格子,每个格子一次长度为 n 的点积。对 n×n 方阵是 O(n³),n 翻倍,工作量变成八倍。Strassen 等算法用更多加法换更少乘法,把指数降到 3 以下,但实际的库大多跑的是朴素方法,只是针对硬件做了分块和向量化。GPU 和 TPU 能把它算得很快,是因为它非常规整:成千上万个互不依赖的点积,没有分支。
乘法的顺序也会改变代价。默认输入下,AB 需要 12 次乘法,BA 需要 18 次,因为共同的内侧维度从 3 变成 2,输出却从 4 个格子变成 9 个。在 ABC 这样的连乘里,括号加在哪里会改变总工作量,尽管结果不变;怎样加括号最好,是一道经典的动态规划题。日常可用的版本是:先往窄的一侧乘。对 n×n 矩阵 A、B 和向量 v,A(Bv) 需要 2n² 次乘法,(AB)v 却需要 n³ + n² 次。
边界情况
- 内侧维度不匹配。 A (2×3) 乘 B (2×2) 要把 3 个数的行和 2 个数的列配对,第三个数没有搭档,所以 AB 不存在。换个顺序,B (2×2) 乘 A (2×3) 可以算,得到 2×3 的结果。"形状错误"预设同时展示了这两种情况。
- 一批输入经过一层。 X (4×3) 乘 W (3×2) 得到 4 个输入 × 2 个分数。WX 需要 2 = 4,算不了:这正是写反了顺序时 PyTorch 报的
mat1 and mat2 shapes cannot be multiplied。 - 行乘列,列乘行。 1×3 的行乘 3×1 的列只得到一个数,也就是点积("行 × 列"预设里是 13)。同样两个向量换个顺序,得到一张 3×3 的表,即外积,需要 9 次乘法,没有加法。
- 形状相同,照样不同。 A = [[1, 2], [3, 4]] 与交换矩阵 B = [[0, 1], [1, 0]]:AB 交换 A 的列,BA 交换 A 的行。两者都是 2×2,都需要 8 次乘法,却每个格子都不一样。
- 可交换的情况。 如果 AB 和 BA 真的相等(B = I、B = A,或两个 1×1 矩阵),比较这一拍会如实说明,而不会声称它们不同。
常见错误
- 逐元素相乘。 NumPy 里的
A * B是逐元素相乘,也接受满足广播规则的不同形状。形状相同时,它就是 Hadamard 积。矩阵乘积是A @ B。 - 行对行配对。 用 A 的第 i 行和 B 的第 j 行来算 C[i][j],得到的是 A·Bᵀ,要求 A 和 B 的列数相同。它确实是一种常用运算(注意力里的 QKᵀ 就是它),只是不是 AB。
- 默认 AB = BA。 调换乘积的顺序会悄悄改变结果,即使形状恰好允许。照数学式子保持顺序,需要转置时就明确写出来。
- 检查错了维度。 必须相等的是内侧维度:A 的列数和 B 的行数。去比外侧维度(A 的行数和 B 的列数)是常见的"差一个转置"错误。
- 把计数器当成加法次数。 长度为 n 的格子需要 n 次乘法,但只需要 n − 1 次加法;画布统计的是乘法,总共 m·n·p 次。