线性代数
两个矩阵相乘,到底把什么乘在了一起?
我们前面已经把矩阵和线性变换联系起来了。一个二维线性变换,只要知道两个标准基向量分别去了哪里,就能够知道整个平面怎样变化。把这两个结果依次放进矩阵的两列,这个变换就被记录下来了。
接下来,我们再往前走一步:如果先做一个变换,再做另一个变换,应该怎样描述整个过程?
比如,先让整个平面绕原点逆时针旋转,然后再做一次水平剪切。我们可以分两步计算,也可以直接给出一个矩阵,让它一次得到最后的结果。
矩阵乘法所记录的,就是这样一个连续执行的过程。把这一点想清楚,为什么要“行乘列”、为什么顺序不能随意交换、为什么又满足结合律,就可以连起来理解。
全文沿用列向量约定:矩阵写在向量左边;输入、输出都用固定的标准坐标系表示。后面的三维旋转也在这个约定下讨论。
先旋转,再剪切:把两个变换接起来
先把每一步单独看清楚
我们继续使用上一篇出现过的两个矩阵:
表示绕原点逆时针旋转
,把
变成
;
表示水平剪切,把
变成
。
这里的剪切可以这样看:高度不变,水平方向的移动量等于
。所以
轴上的点不动,位于
的点向右移动
,位于
的点向左移动
。
现在给一个输入向量:
先旋转:
再对这个结果做剪切:
大家看下面这张图。蓝色箭头追踪的是同一个输入向量,绿色、黄色箭头追踪的是原来的两个标准基向量。每一步都接着上一步的结果继续变化。
图中蓝色区域是同一个矩形在各阶段的位置。网格只显示有限的观察范围,变换规则仍然作用于整个平面。
为什么整体效果仍然能写成一个矩阵?
先后执行两个线性变换,得到的复合变换仍然是线性的。我们把两个变换分别记为,对任意向量和实数
,有:
这个式子只是连续使用了两次“保持线性组合”的规则。既然整体仍然是线性变换,我们就可以继续追踪基向量,把整个过程写成一个矩阵。
这个矩阵记为,满足:
等号要求对任意输入向量成立。它说的是,两步变换可以合并成一个变换,而不只是刚才那个输入恰好算出相同的结果。教材对这种对应关系有正式说明,见 Interactive Linear Algebra:Composition and Matrix Multiplication。
为什么先做的变换,写在右边?
这里特别容易把顺序看反。我们说“先旋转,再剪切”,写出来却是,剪切矩阵
放在左边。
原因可以直接从函数记号读出来:要先算括号里面的
,再把结果交给
。矩阵写法沿用了同样的顺序。
因此,在当前列向量约定下:
离输入向量最近的矩阵先执行。看到,我们就把它读成:先做
所表示的变换,再做
所表示的变换。
用代码同时走两条路线
下面使用 NumPy。首次运行需要安装依赖:python3 -m pip install numpy。本篇代码块按顺序执行;一维数组用于保存向量,@表示矩阵乘法,规则可对照 NumPy 官方文档:matmul。
import numpy as np R = np.array([[0, -1], [1, 0]]) S = np.array([[1, 1], [0, 1]]) v = np.array([2, 1]) rotated = R @ v step_by_step = S @ rotated C = S @ R print("旋转后:", rotated) print("再剪切:", step_by_step) print("复合矩阵:\n", C, sep="") print("直接计算:", C @ v) print("两种方式一致:", np.array_equal(step_by_step, C @ v))输出结果:
旋转后: [-1 2] 再剪切: [1 2] 复合矩阵: [[ 1 -1] [ 1 0]] 直接计算: [1 2] 两种方式一致: True逐步计算和直接计算都得到了。我们还得到一个复合矩阵:
现在问题就变成了:这个矩阵中的四个数字,能不能从基向量的去向推出来?
矩阵乘法,为什么要这样计算?
先追踪第一根基向量
这里换另一组矩阵,把计算过程完整拆开:
我们要计算,也就是先做
,再做
。
上一篇已经讲过:矩阵的第一列,是第一根标准基向量的像。因此,复合矩阵的第一列,就是 经历两次变换以后到达的位置。
第一步,读取 的第一列:
第二步,把这个中间结果交给:
所以,乘积的第一列是T。
第二根基向量,按照相同的方式继续
读取 的第二列,可以知道
先到达
。再做
:
把两个最终结果按列放好,就得到:
绿色箭头最后到达,黄色箭头最后到达
。这两根箭头提供的信息,恰好就是复合矩阵的两列。
所以,我们现在可以把计算规则说得很具体:用左边的矩阵,分别乘右边矩阵的每一列,再把结果依次放进乘积的对应列。
代码可以直接照着这个过程写:
M1 = np.array([[1, -2], [1, 0]]) M2 = np.array([[0, 2], [1, 0]]) first = M2 @ M1[:, 0] second = M2 @ M1[:, 1] by_columns = np.column_stack((first, second)) print("第一列:", first) print("第二列:", second) print("按列组成:\n", by_columns, sep="") print("与矩阵乘法一致:", np.array_equal(by_columns, M2 @ M1))输出结果:
第一列: [2 1] 第二列: [ 0 -2] 按列组成: [[ 2 0] [ 1 -2]] 与矩阵乘法一致: True这里M1[:, 0]取出第一列,M1[:, 1]取出第二列;np.column_stack把它们的最终去向重新按列排列。每一行代码,都能对应到图中的一步。
把数字换成字母,就得到一般公式
设:
先做,第一根标准基到达
。再做
,就得到:
第二根标准基经过相同的过程,最终到达。因此:
现在再看“左矩阵的第行,乘右矩阵的第
列”,就知道它在计算什么了:它在读取第
根标准基经过整个复合变换后,第
个坐标是多少。
一般情况下,若有
列,记乘积为
,元素公式就是:
这里的遍历中间向量的所有分量。行乘列是展开后的计算方式,追踪基向量则说明了这些分量为什么要这样相乘、相加。
为什么矩阵乘法一般不能交换顺序?
把剪切和旋转调换一下
我们回到最开始的。刚才做的是先旋转,再剪切,复合矩阵为
。
现在先剪切,再旋转。输入仍然是,先经过
到达
,再经过
到达
。
而原来的顺序,终点是。同一个输入、同样两种变换,执行顺序一变,输出就变了。
大家可以重点看两幅图中的蓝色箭头。剪切的方向由固定坐标系确定,它与旋转接在一起时,先后关系会影响最终位置。我们把两个乘积都写出来:
矩阵不同,对应的线性变换也不同。用代码验证:
print("先旋转后剪切 SR:\n", S @ R, sep="") print("先剪切后旋转 RS:\n", R @ S, sep="") print("SR 作用于 v:", (S @ R) @ v) print("RS 作用于 v:", (R @ S) @ v)输出结果:
先旋转后剪切 SR: [[ 1 -1] [ 1 0]] 先剪切后旋转 RS: [[ 0 -1] [ 1 1]] SR 作用于 v: [1 2] RS 作用于 v: [-1 3]“一般不交换”,不等于“永远不相等”
这里要把结论说准确:矩阵乘法不满足普遍的交换律。我们不能对任意两个矩阵都写。
但确实存在可以交换的矩阵。例如,单位矩阵表示什么都不改变,同一大小的方阵总有
。某些其他变换组合,也可能得到相同的效果。
还有一点:不同的变换,可能把某些特定向量送到相同位置。比如刚才的两个顺序都把零向量送到零向量。因此,仅仅检查一个输入得到了相同输出,不能证明两个矩阵相同;要证明它们不同,找到一个输出不同的输入就足够了。
为什么不能交换,却可以改变括号?
结合律保持了原来的执行顺序
接下来考虑三个线性变换,矩阵分别记为。让它们按
、
、
的顺序执行:
我们可以先把合成一个矩阵,然后接上
,写成
;也可以先把
合成一个矩阵,再接在
后面,写成
。
这两种分组,对任意输入执行的完整过程都是“先,再
,最后
”。所以它们对每个标准基向量的输出相同,矩阵的每一列也就相同。这就得到了:
这个几何解释对应的,就是矩阵乘法的结合律。原课程对顺序与分组的讨论,可以对照 3Blue1Brown:Matrix multiplication as composition 阅读。
这里重点注意:改括号,是改变预先合并哪几步;交换矩阵,是改变真正执行的先后顺序。两件事要分开。
再加入一个水平方向的拉伸
设把横坐标扩大到原来的两倍,纵坐标保持不变:
我们先做,再做
,最后做
。两种分组应该给出同一个复合矩阵:
接着运行:
D = np.array([[2, 0], [0, 1]]) left = (S @ R) @ D right = S @ (R @ D) print("共同的复合矩阵:\n", left, sep="") print("两种分组一致:", np.array_equal(left, right)) print("先 D,再 R,最后 S:", S @ (R @ (D @ v)))输出结果:
共同的复合矩阵: [[ 2 -1] [ 2 0]] 两种分组一致: True 先 D,再 R,最后 S: [3 4]沿着向量也能核对:先到
,再到
,最后到
。
上面的代码是在检查具体例子,一般性的理由仍然是前面的复合过程。这里使用小整数和精确比较;换成浮点数据时,不同分组可能因为舍入产生微小差异,通常需要使用带适当容差的np.allclose判断数值是否接近。
推广到三维,改变的是列数,思路仍然一样
三根标准基,记录三列
二维的标准基有两根,三维的标准基则有三根:
它们也常被记为。对一个从
到
的线性变换,只要追踪这三根基向量,就能把结果依次写进一个
矩阵:
上式中的每一个都是一整列,包含三个坐标。因此,矩阵一共有九个数。对任意输入:
这里仍然是在按输入坐标缩放各列,然后相加。三维画面更复杂,计算背后的线性组合关系并没有改变。对应课程见 3Blue1Brown:Three-dimensional linear transformations。
绕 y 轴旋转的矩阵,怎样从图中读出来?
我们采用右手坐标系,让向量绕固定的轴,按右手定则旋转正
。这里的正方向可以理解为:右手拇指指向
,其余手指弯曲的方向就是旋转方向。
按照这个约定,三根基向量分别变成:
其中,就在旋转轴上,所以保持不动。把三个结果按列排列:
灰线保留了固定坐标轴,彩色箭头则追踪原来的基向量。这里观察的是向量在固定坐标系中的旋转,后面的矩阵也都按这个方式解释。
任意输入的计算结果,可以直接从三列读出来:
比如会到达
。用代码检查三列和这个输出:
Ry = np.array([[0, 0, 1], [0, 1, 0], [-1, 0, 0]]) v3 = np.array([1, 2, 3]) print("e1 的像:", Ry[:, 0]) print("e2 的像:", Ry[:, 1]) print("e3 的像:", Ry[:, 2]) print("Ry 作用于 v3:", Ry @ v3)输出结果:
e1 的像: [ 0 0 -1] e2 的像: [0 1 0] e3 的像: [1 0 0] Ry 作用于 v3: [ 3 2 -1]三维的矩阵乘法,也是在接着做变换
现在再接上一个绕固定轴、按右手定则旋转正
的变换:
它让变成
。从
一侧看向原点,在
平面中就是逆时针旋转
。
先绕轴,再绕
轴,复合矩阵仍然从右往左写:
我们还是可以只看基向量。例如, 先经
到达
,再经
到达
。所以乘积的第三列就是
。
这里两次旋转都围绕同一个固定坐标系的轴进行。图中颜色始终跟随原来的那根基向量,所以第三幅图的绿色、黄色、蓝色箭头,分别对应乘积的第一、第二、第三列。
沿着输入向量看,整个过程是:
继续运行:
Rz = np.array([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) C3 = Rz @ Ry print("先 y 后 z 的复合矩阵:\n", C3, sep="") print("逐步计算:", Rz @ (Ry @ v3)) print("直接计算:", C3 @ v3) print("交换顺序:", (Ry @ Rz) @ v3)输出结果:
先 y 后 z 的复合矩阵: [[ 0 -1 0] [ 0 0 1] [-1 0 0]] 逐步计算: [-2 3 -1] 直接计算: [-2 3 -1] 交换顺序: [3 1 2]交换两次旋转,结果又变了。二维和三维在这里遵循相同的规则:每一列追踪一个基向量,右侧变换先执行,左侧变换接着处理它的输出。
两个矩阵,是不是总能放在一起相乘?
前面的矩阵都是方阵,输入、输出维数相同,所以很容易忽略维数问题。现在把中间过程写出来:
接受
个分量,输出
个分量,所以它是
矩阵。
必须能够接受这
个分量,才能接着往下算,因此它是
矩阵。
整个过程从到
,乘积的大小自然就是:
这就是“左边列数等于右边行数”的原因:前一个变换输出的分量数,要与后一个变换接收的分量数匹配。
例如,一个矩阵把二维向量送入三维空间,一个
矩阵再把三维向量送回二维空间。按这个顺序复合,会得到
矩阵;反过来复合,则得到
矩阵。它们连输入空间都不同,不能直接写成两个相等的矩阵。有些其他大小的矩阵,调换顺序后甚至无法相乘。
实际写代码时,为什么要先求出复合矩阵?
如果只有一个向量,分两步计算就很直观。但如果同一组变换要应用到很多个顶点,我们可以先求出复合矩阵,然后反复使用。
以前面的矩形为例,把四个顶点分别放进四列:
每一列都是一个位置向量,依次对应、
、
、
。同一个矩阵左乘
,就会分别作用于这些列。
我们已经算出,因此:
points = np.array([[0, 2, 2, 0], [0, 0, 1, 1]]) result = C @ points print("每列一个变换后的顶点:\n", result, sep="") print("与逐步变换一致:", np.array_equal(result, S @ (R @ points)))输出结果:
每列一个变换后的顶点: [[ 0 2 1 -1] [ 0 2 2 0]] 与逐步变换一致: True这里的第三列就是,正好对应第一张过程图里追踪的蓝色箭头。按列连接四个输出点,就能得到最后的平行四边形。
对于这组固定的二维变换,预先保存,之后每一批顶点只需要应用这个复合矩阵。需要保留中间阶段时,仍然可以分步计算。具体采用哪一种方式,要看是否复用相同的变换,以及是否需要观察每一步。
还有一个代码细节要一直记住:对这里的 NumPy 数组,S @ R才表示变换的复合;S * R表示对应元素相乘。两者计算的是不同的东西。
到这里,可以自己检查哪几件事?
先不要急着重新背公式。大家可以试着离开代码,回答下面两个问题。
第一个:不展开完整乘积,怎样求 的第二列?只追踪
:它先到达
,再到达
,这就是第二列。
第二个:与
为什么相同,而
不一定与它们相同?前两种分组都执行“先拉伸、再旋转、最后剪切”;
则执行“先拉伸、再剪切、最后旋转”,真正的执行顺序发生了变化。
如果能把这两个问题讲清楚,再看到矩阵乘法,就能够同时读出它的公式和它所描述的空间过程。
下一篇,我们继续观察空间怎样变化:一个线性变换,会把面积或体积扩大多少倍?有没有把空间压扁?又有没有改变方向?这些问题会把我们带到行列式。