☰ inferserver 的数学基础

15. 奇异值分解:几何直觉、手算过程与低秩近似

15.1 为什么这一章在这里

前面 14 节覆盖的都是推理服务里逐行出现的公式:softmax、RMSNorm、注意力、浮点误差。奇异值分解(singular value decomposition,SVD)不属于「每个 token 都要算一遍」的那类公式,但它解释了后面会反复借用的一件事:一个矩阵的「有效维度」可以比它的形状小得多。

具体一点:

所以这一章不追代码行号,而是把 SVD 本身讲透:先建立几何图像,再从图像推出定义,然后手算一个 \(3\times2\) 的例子,最后用代码验证「截断」到底丢了多少东西。

15.2 矩阵是线性变换:列就是基向量的像

线性代数课上通常把 \(A\mathbf{x}=\mathbf{b}\) 讲成一堆线性方程。另一种看法更有用:\(A\) 是一个函数,把 \(\mathbb{R}^n\) 里的向量搬到 \(\mathbb{R}^m\) 里。

\[ f(\mathbf{x})=A\mathbf{x},\qquad A\in\mathbb{R}^{m\times n},\ \mathbf{x}\in\mathbb{R}^{n}. \]

为什么矩阵乘法长这个样子?把 \(\mathbf{x}\) 用标准基 \(\mathbf{e}_1,\dots,\mathbf{e}_n\) 展开,再用 \(f\) 的线性性(可加、齐次)拆开:

\[ \begin{aligned} \mathbf{x}&=\sum_{j=1}^{n}x_j\mathbf{e}_j,\\ f(\mathbf{x})&=f\Big(\sum_j x_j\mathbf{e}_j\Big)=\sum_j x_j f(\mathbf{e}_j)=\sum_j x_j\,\mathbf{a}_j, \end{aligned} \]

其中 \(\mathbf{a}_j=f(\mathbf{e}_j)\) 就是 \(A\) 的第 \(j\) 列。也就是说:

几何定义

矩阵的第 \(j\) 列 = 第 \(j\) 个标准基向量被搬到的地方;而 \(A\mathbf{x}\) 就是「以 \(\mathbf{x}\) 的分量为系数,对各列做线性组合」。

举个 \(2\times2\) 的例子,\(A=\begin{bmatrix}2&-1\\0&1\end{bmatrix}\),\(\mathbf{x}=\begin{bmatrix}1\\2\end{bmatrix}\):

\[ \begin{bmatrix}2&-1\\0&1\end{bmatrix}\begin{bmatrix}1\\2\end{bmatrix} =1\cdot\begin{bmatrix}2\\0\end{bmatrix}+2\cdot\begin{bmatrix}-1\\1\end{bmatrix} =\begin{bmatrix}0\\2\end{bmatrix}. \]

看列就能直接读出这个变换干了什么:\(\mathbf{e}_1\) 被拉长 2 倍仍指向右,\(\mathbf{e}_2\) 被搬到左上。整个平面被「剪切 + 翻转」了一下,单位正方形变成平行四边形。行列式是 2,正好等于面积的放大倍数。

能做的操作只有那几种:旋转、翻转、拉伸/压缩、剪切、投影(把维度压掉)。不能做的是「弯折」:线性变换必须把直线映成直线,把等距的点映成等距的点。

15.3 正交矩阵:只转不拉

SVD 的几何内容可以一句话说完:任何线性变换都可以拆成「转一下、沿正交方向拉伸、再转一下」。先把「只转不拉」这一类说清楚,后面就顺了。

方阵 \(Q\) 是正交矩阵,当且仅当它的列两两正交且都是单位向量,即 \(Q^\top Q=I\)(实矩阵情形),等价地 \(Q^{-1}=Q^\top\)。它有三个好性质,都是一行推导:

  1. 角度不变:\((Q\mathbf{u})^\top(Q\mathbf{v})=\mathbf{u}^\top Q^\top Q\mathbf{v}=\mathbf{u}^\top\mathbf{v}\)。
  2. 长度不变:\(\lVert Q\mathbf{v}\rVert_2^2=\mathbf{v}^\top Q^\top Q\mathbf{v}=\lVert\mathbf{v}\rVert_2^2\)。
  3. 面积(体积)绝对值不变:\(1=\det(I)=\det(Q^\top Q)=(\det Q)^2\),故 \(\det Q=\pm1\)。符号是 \(+1\) 表示纯旋转,\(-1\) 表示带一次翻转(手性改变)。

所以正交矩阵就是「刚体运动」在原点处的版本:旋转或镜像,不改变任何长度、夹角、面积。二维里它长这样:

\[ \begin{aligned} \text{旋转:}\quad Q&=\begin{bmatrix}\cos\theta&-\sin\theta\\ \sin\theta&\cos\theta\end{bmatrix},\\[2pt] \text{镜像:}\quad Q&=\begin{bmatrix}\cos\theta&\sin\theta\\ \sin\theta&-\cos\theta\end{bmatrix}. \end{aligned} \]
常见混淆

「正交」在这里指列向量互相垂直、每列长度为 1,不是「矩阵元素正交」。名字起得不好,只能记牢。

15.4 几何核心:先转、再拉、再转

现在做那个关键实验。拿一个单位圆(二维时比较好画;一般 \(n\) 维是单位球),随便乘一个矩阵 \(M\)。结果是什么形状?

答案是椭圆(\(n\) 维时是超椭球)。原因是椭圆的定义就是「到两个定点的距离之和为常数」……这个定义在这里不好用,换个等价说法:存在一组正交方向,使得在这组方向上,球被拉伸的倍数恰好是常数。这正是下面要构造的。

先给结论,再推:

几何本质

先旋转输入空间,再沿坐标轴拉伸/压缩(或翻转),再旋转输出空间。任何矩阵都只做这三件事——它不会「剪切」,只要你允许它在输入和输出两端各转一次。

别把「换列」当旋转

有些讲义会把「交换单位阵的两列」当成旋转的例子,这是错的,两层都错:
① 交换 \(\begin{bmatrix}1&0\\0&1\end{bmatrix}\) 的两列得到 \(\begin{bmatrix}0&1\\1&0\end{bmatrix}\),它把 \((x,y)\) 映成 \((y,x)\),是关于 \(y=x\) 的镜像,\(\det=-1\);
② 对角矩阵 \(\operatorname{diag}(\alpha_1,\alpha_2)\) 只沿两个标准基方向各自缩放,不能把两个轴「混」起来,所以它做不到 90° 旋转(\(90^\circ\) 的矩阵是 \(\begin{bmatrix}0&-1\\1&0\end{bmatrix}\),非对角元非零,\(\det=+1\))。对角矩阵唯一能实现的旋转是 \(\operatorname{diag}(-1,-1)\),即 180°。
判断口径:先看 \(\det\) 的符号(\(-1\) 一定含镜像),再看标准基方向是否被打乱。

用剪切矩阵亲手验证一下。取

\[ M=\begin{bmatrix}1&1\\0&1\end{bmatrix}, \]

它把单位正方形剪成平行四边形,看起来完全不像「拉伸」。但它的 SVD 是

\[ \begin{aligned} M&=U\Sigma V^\top,\\ \sigma_1&=\varphi\approx1.618,\qquad \sigma_2=\tfrac{1}{\varphi}\approx0.618 . \end{aligned} \]
剪切矩阵的 SVD 三步:单位圆先被 Vᵀ 旋转,再被 Σ 沿两轴拉伸成椭圆,最后被 U 旋转回原朝向
图 1 前面那个剪切矩阵 \(M\) 作用在单位圆上,拆成三步看。(A) 一步到位:椭圆;(B) 先转 \(V^\top\)(蓝色虚线是旋转后的圆,蓝色箭头是 \(\mathbf{v}_1,\mathbf{v}_2\) 的方向),\(\Sigma\) 只沿坐标轴把半径拉成 \(\sigma_1,\sigma_2\)(红);(C) 再转 \(U\),得到的椭圆与 (A) 逐点重合(虚线压在实线上)。

其中 \(\varphi\) 是黄金比。换算成角度:\(V\) 是 \(-58.28^\circ\) 的旋转,\(U\) 是 \(+31.72^\circ\) 的旋转。于是「剪切」= 先把输入平面逆时针转回去 \(58.28^\circ\)(进入一个特殊朝向,在这个朝向下剪切恰好表现为沿两轴的拉伸),沿两轴分别拉 \(1.618\) 和 \(0.618\) 倍,再把结果转 \(31.72^\circ\)。

顺便验算两个恒等式:

\[ \begin{aligned} |\det M|&=\sigma_1\sigma_2=\varphi\cdot\tfrac{1}{\varphi}=1,\\ \kappa(M)&=\frac{\sigma_1}{\sigma_2}=\varphi^2\approx2.618 . \end{aligned} \]

行列式 1 说明面积没变(剪切确实不改面积),\(\kappa\) 叫条件数,说这个方向上「拉长」和「压短」差了 2.618 倍——它衡量这个线性变换有多「扁」,后面 15.11 会用。

圆的像在椭圆上的对应关系很直观:半径方向被拉得最多的那条,倍数正好是 \(\sigma_1\);在非零奇异值的范围内,拉得最少的那条,倍数由 \(\sigma_p\) 给出,其中 \(p=\min(m,n)\)。如果某个 \(\sigma_i=0\),说明这个方向被压成了 0——球在这个维度上被拍扁了,矩阵不满秩。

15.5 从图像推出公式

上面的几何描述,可以原封不动地写成代数。设输入空间 \(\mathbb{R}^n\) 有一组正交单位基 \(\mathbf{v}_1,\dots,\mathbf{v}_n\)(就是「转回去」之后的那组坐标轴)。因为它们是正交单位基,任何向量都能这样展开(就是在做投影):

\[ \mathbf{x}=\sum_{j=1}^{n}(\mathbf{x}\cdot\mathbf{v}_j)\,\mathbf{v}_j . \]

两边左乘 \(M\),用线性性把系数提到外面:

\[ M\mathbf{x}=\sum_{j=1}^{n}(\mathbf{x}\cdot\mathbf{v}_j)\,M\mathbf{v}_j . \]

几何上,我们要求输入方向 \(\mathbf{v}_j\) 经过 \(M\) 后只产生一个确定的输出方向,长度缩放为 \(\sigma_j\) 倍。由于输出空间可能是 \(\mathbb{R}^m\),输出方向和输入方向不在同一个空间,所以给它另起个名字 \(\mathbf{u}_j\)(单位向量):

\[ M\mathbf{v}_j=\sigma_j\mathbf{u}_j . \]

代回去,再把标量 \((\mathbf{x}\cdot\mathbf{v}_j)\) 换成 \(\mathbf{v}_j^\top\mathbf{x}\),挪到后面:

\[ M\mathbf{x}=\sum_{j=1}^{n}\sigma_j\,\mathbf{u}_j\,(\mathbf{v}_j^\top\mathbf{x}) =\Big(\sum_{j=1}^{n}\sigma_j\mathbf{u}_j\mathbf{v}_j^\top\Big)\mathbf{x}. \]

这个式子对任意 \(\mathbf{x}\) 都成立,所以括号里的矩阵就是 \(M\) 本身:

\[ \boxed{M=\sum_{j=1}^{p}\sigma_j\,\mathbf{u}_j\mathbf{v}_j^\top=U\Sigma V^\top} \]

其中 \(U=[\mathbf{u}_1\ \cdots\ \mathbf{u}_m]\)、\(V=[\mathbf{v}_1\ \cdots\ \mathbf{v}_n]\) 是正交矩阵,\(p=\min(m,n)\),\(\Sigma\) 是把 \(\sigma_1\ge\cdots\ge\sigma_p\ge0\) 摆在左上角、其余补零的 \(m\times n\) 矩阵。

每一项 \(\sigma_j\mathbf{u}_j\mathbf{v}_j^\top\) 是一个秩 1 矩阵:把输入往 \(\mathbf{v}_j\) 方向投影、乘 \(\sigma_j\)、再送到 \(\mathbf{u}_j\) 方向。所以 SVD 的意思也可以说成:

另一种说法

任何矩阵都是若干个「秩 1 切片」的加权和,权重就是奇异值。这也是为什么「丢掉最小的那些切片」等于「低秩近似」。

15.6 尺寸、术语与秩

约定 \(A\in\mathbb{R}^{m\times n}\),\(p=\min(m,n)\)。三种矩阵的尺寸容易记错,列一下:

符号尺寸内容
\(U\)\(m\times m\)左奇异向量(left singular vectors)作为列,正交
\(\Sigma\)\(m\times n\)对角线上是 \(\sigma_1\ge\cdots\ge\sigma_p\ge0\),其余位置为 0
\(V\)\(n\times n\)右奇异向量(right singular vectors)作为列,正交

几个立刻能读出来的性质:

和特征分解的区别

特征分解 \(A=Q\Lambda Q^{-1}\) 只对方阵、且要有足够多线性无关特征向量时才存在;\(A\) 不是实对称时特征向量还未必正交。SVD 对任意实矩阵都存在,\(\mathbf{u}\) 与 \(\mathbf{v}\) 各自正交,\(\sigma\) 也一定是非负实数。所以工程上能用的永远是 SVD,而不是特征分解。

四个基本子空间,一套正交基。线性代数的四个子空间和 SVD 是一一对应的,看懂这张表就不用再背维数公式:

子空间所在空间维数SVD 给的正交基
列空间(值域)\(\mathbb{R}^m\)\(r\)\(\mathbf{u}_1,\dots,\mathbf{u}_r\)
左零空间(\(A^\top\) 的零空间)\(\mathbb{R}^m\)\(m-r\)\(\mathbf{u}_{r+1},\dots,\mathbf{u}_m\)
行空间\(\mathbb{R}^n\)\(r\)\(\mathbf{v}_1,\dots,\mathbf{v}_r\)
零空间\(\mathbb{R}^n\)\(n-r\)\(\mathbf{v}_{r+1},\dots,\mathbf{v}_n\)

所以 \(\mathbb{R}^n=\) 行空间 \(\oplus\) 零空间、\(\mathbb{R}^m=\) 列空间 \(\oplus\) 左零空间,两组基都是标准正交的。这也顺手解释了秩–零化度定理:\(r+(n-r)=n\)。前面 15.13 里「零空间的基向量直接读出来」,就是用了这张表的第四行。

15.7 怎么算:从 \(A^\top A\) 出发

SVD 和特征分解的关系只有两行。把 \(A=U\Sigma V^\top\) 代进 \(A^\top A\):

\[ \begin{aligned} A^\top A&=(U\Sigma V^\top)^\top(U\Sigma V^\top)\\ &=V\Sigma^\top U^\top U\Sigma V^\top =V(\Sigma^\top\Sigma)V^\top . \end{aligned} \]

\(\Sigma^\top\Sigma\) 是 \(n\times n\) 的对角矩阵,对角元是 \(\sigma_j^2\)(多出来的补零)。右乘 \(V\) 的第 \(j\) 列 \(\mathbf{v}_j\),得到

\[ A^\top A\,\mathbf{v}_j=\sigma_j^2\,\mathbf{v}_j . \]

同理

\[ AA^\top=U(\Sigma\Sigma^\top)U^\top\quad\Longrightarrow\quad AA^\top\mathbf{u}_j=\sigma_j^2\mathbf{u}_j . \]
计算路线

\(\mathbf{v}_j\) 是 \(A^\top A\) 的单位特征向量,\(\mathbf{u}_j\) 是 \(AA^\top\) 的单位特征向量,\(\sigma_j=\sqrt{\lambda_j}\)。\(A^\top A\) 与 \(AA^\top\) 都是对称半正定矩阵,特征值非负,所以开方总是合法的。

因此手算流程是:

  1. 算 \(A^\top A\)(\(n\times n\))与 \(AA^\top\)(\(m\times m\)),挑小的那个用;两步都用也行,正好交叉验证。
  2. 解特征方程 \(\det(A^\top A-\lambda I)=0\) 得 \(\lambda_1\ge\cdots\ge\lambda_n\ge0\),开方得 \(\sigma_j\)。
  3. 对每个 \(\lambda_j\) 解 \((A^\top A-\lambda_j I)\mathbf{v}=0\),单位化得 \(\mathbf{v}_j\),排成 \(V\)。
  4. 用 \(\mathbf{u}_j=A\mathbf{v}_j/\sigma_j\) 反算左奇异向量(只对 \(\sigma_j>0\) 有效)。
  5. 剩下的 \(\mathbf{u}\) 用「与已有 \(\mathbf{u}\) 正交」补齐(或直接解 \(A^\top\mathbf{x}=0\) 再 Gram–Schmidt)。
数值上别这么做

手算用 \(A^\top A\) 很舒服,但浮点数下它会把条件数平方:\(\kappa(A^\top A)=\kappa(A)^2\)。\(\sigma_1/\sigma_n=10^8\) 时,\(\lambda\) 的动态范围是 \(10^{16}\),双精度直接丢掉小的一半。所以 LAPACK / NumPy 走的是双对角化 + QR 迭代(Golub–Kahan),不是显式构造 \(A^\top A\)。这也是为什么截断 SVD 要用库函数,而不是自己写 \(A^\top A\) 加 eig。

15.8 手算一个 \(3\times2\) 的例子

取 \(A=\begin{bmatrix}1&0\\0&1\\2&2\end{bmatrix}\),\(m=3,n=2\),所以 \(U\) 是 \(3\times3\)、\(\Sigma\) 是 \(3\times2\)、\(V\) 是 \(2\times2\)。

第一步:\(n=2\lt m=3\),用小的那个:

\[ A^\top A=\begin{bmatrix}1&0&2\\0&1&2\end{bmatrix}\begin{bmatrix}1&0\\0&1\\2&2\end{bmatrix} =\begin{bmatrix}5&4\\4&5\end{bmatrix}. \]

第二步:特征多项式

\[ \begin{aligned} \det\begin{bmatrix}5-\lambda&4\\4&5-\lambda\end{bmatrix} &=(5-\lambda)^2-16\\ &=\lambda^2-10\lambda+9, \end{aligned} \]

两根 \(\lambda_1=9,\lambda_2=1\),于是 \(\sigma_1=3,\sigma_2=1\),\(\Sigma=\begin{bmatrix}3&0\\0&1\\0&0\end{bmatrix}\)。

第三步:对 \(\lambda_1=9\),\((A^\top A-9I)\mathbf{v}=0\) 给出 \(-4v_1+4v_2=0\),即 \(v_1=v_2\),单位化得 \(\mathbf{v}_1=\frac{1}{\sqrt2}\begin{bmatrix}1\\1\end{bmatrix}\)。对 \(\lambda_2=1\),得 \(-v_1=v_2\),故 \(\mathbf{v}_2=\frac{1}{\sqrt2}\begin{bmatrix}1\\-1\end{bmatrix}\)。

\[ V=\frac{1}{\sqrt2}\begin{bmatrix}1&1\\1&-1\end{bmatrix},\qquad V^\top V=I . \]

第四步:用 \(\mathbf{u}_j=A\mathbf{v}_j/\sigma_j\) 反算前两列:

\[ \begin{aligned} \mathbf{u}_1&=\frac{1}{3}\cdot\frac{1}{\sqrt2}\begin{bmatrix}1\\1\\4\end{bmatrix} =\frac{1}{\sqrt{18}}\begin{bmatrix}1\\1\\4\end{bmatrix},\\ \mathbf{u}_2&=\frac{1}{1}\cdot\frac{1}{\sqrt2}\begin{bmatrix}1\\-1\\0\end{bmatrix}. \end{aligned} \]

第五步:还差 \(\mathbf{u}_3\),要求单位长度且同时垂直于 \(\mathbf{u}_1,\mathbf{u}_2\)。注意 \(\mathbf{u}_3\perp\mathbf{u}_2\)(分量 \((1,-1,0)/\sqrt2\))相当于前两个分量相等,设 \(\mathbf{u}_3=(a,a,c)\);再由 \(\mathbf{u}_3^\top\mathbf{u}_1=0\) 得 \(2a+4c=0\),即 \(a=-2c\)。取 \(c=\frac13\) 归一化得

\[ \mathbf{u}_3=\frac{1}{3}\begin{bmatrix}-2\\-2\\1\end{bmatrix}. \] \[ \mathbf{u}_3\cdot\mathbf{u}_1=\frac{-2-2+4}{3\sqrt{18}}=0 . \]

于是

\[ \begin{aligned} U&=\begin{bmatrix} \frac{1}{\sqrt{18}}&\frac{1}{\sqrt2}&-\frac23\\[2pt] \frac{1}{\sqrt{18}}&-\frac{1}{\sqrt2}&-\frac23\\[2pt] \frac{4}{\sqrt{18}}&0&\frac13 \end{bmatrix}. \end{aligned} \] \[ \Sigma=\begin{bmatrix}3&0\\0&1\\0&0\end{bmatrix}, \qquad V^\top=\frac{1}{\sqrt2}\begin{bmatrix}1&1\\1&-1\end{bmatrix}. \] \[ A=U\Sigma V^\top . \]

验算:\(\lVert A\rVert_F^2=1+0+0+1+4+4=10\),而 \(\sigma_1^2+\sigma_2^2=9+1=10\),一致。

秩 1 近似:只保留最大的切片 \(\sigma_1\mathbf{u}_1\mathbf{v}_1^\top\):

\[ \begin{aligned} A_1&=\sigma_1\mathbf{u}_1\mathbf{v}_1^\top\\ &=3\cdot\frac{1}{\sqrt{18}}\begin{bmatrix}1\\1\\4\end{bmatrix} \cdot\frac{1}{\sqrt2}\begin{bmatrix}1&1\end{bmatrix}\\ &=\begin{bmatrix}0.5&0.5\\0.5&0.5\\2&2\end{bmatrix}. \end{aligned} \]

对比原矩阵 \(A\),误差

\[ \begin{aligned} \lVert A-A_1\rVert_F&=\sigma_2=1,\\ \frac{\lVert A-A_1\rVert_F}{\lVert A\rVert_F}&=\frac{1}{\sqrt{10}}\approx31.6\%, \end{aligned} \]

而「能量」保住了 \(\sigma_1^2/\sum_j\sigma_j^2=9/10=90\%\)。逐元素 MSE 是 \(1/6\approx0.1667\)。

用 NumPy 算同一个矩阵会得到符号翻转的版本(第一列整体取负)。不是算错:同时把 \(\mathbf{u}_j,\mathbf{v}_j\) 乘 \(-1\),\(\mathbf{u}_j\mathbf{v}_j^\top\) 不变,SVD 依然成立。

15.9 三种形态:full、compact、truncated

同一个分解,工程上有三种切法。设 \(r=\operatorname{rank}A\le p=\min(m,n)\)。

NumPy 的 svd 默认 full_matrices=True,返回 full 形式:\(U\) 是 \(m\times m\)、\(V^\top\) 是 \(n\times n\),多出来的列不参与重构(它们补足左右零空间的正交基)。设 full_matrices=False 得到的是 reduced/economy 形式:\(U\) 是 \(m\times p\)、\(V^\top\) 是 \(p\times n\),\(p=\min(m,n)\)。它不会根据数值秩自动删掉接近 0 的奇异值;若要得到前面定义的 compact 形式,还需按秩阈值保留对应部分。要做截断近似则自己切前 \(k\) 列。注意 \(V^\top\) 是转置后的,行才是右奇异向量,这一点最容易写错。

为什么「砍掉小的」是合理的?下面这个定理是全部低秩近似的依据。

Eckart–Young–Mirsky

设 \(A_k=\sum_{j\le k}\sigma_j\mathbf{u}_j\mathbf{v}_j^\top\),则对所有秩不超过 \(k\) 的矩阵 \(B\):

\[ \begin{aligned} \lVert A-A_k\rVert_F&=\sqrt{\textstyle\sum_{j>k}\sigma_j^2} \le\lVert A-B\rVert_F,\\ \lVert A-A_k\rVert_2&=\sigma_{k+1}\le\lVert A-B\rVert_2 . \end{aligned} \]

也就是说 \(A_k\) 在 Frobenius 范数和谱范数下同时是最优秩 \(k\) 近似。误差只看「被丢掉的那些奇异值」,没有任何玄学。

推论:相对误差的平方 = 被丢掉的能量占比,即

\[ \frac{\lVert A-A_k\rVert_F^2}{\lVert A\rVert_F^2} =\frac{\sum_{j>k}\sigma_j^2}{\sum_{j\le r}\sigma_j^2}. \]

实际做决策时,只要画一条「累计能量 \(\sum_{j\le k}\sigma_j^2/\sum_j\sigma_j^2\) 关于 \(k\) 的曲线」,看它在哪儿拐平就够了。

15.10 数值实验:奇异值衰减有多快

下面用一个 400×300 的合成灰度图做实验:四个硬边色块(高对比、位置规则)+ 一条细竖条纹带(高频)+ 标准差 2 的轻噪声。取「硬边」而不是平滑渐变,是因为真实的权重矩阵、注意力图也是这种「大块结构 + 少量高频」的形态;平滑渐变会让 SVD 显得比实际更强。完整代码在 15.11。

原图与 k=5、20、100 的 SVD 重建对比:k=5 时硬边出现振铃,k=100 肉眼难辨
图 2 原图与截断重建。\(k=5\) 时硬边周围出现明显的波纹(振铃),\(k=20\) 时基本消失,\(k=100\) 与 \(400\times300\) 的满秩重建几乎无法区分。
残差图,按 3 倍增益显示:k=5 时残差集中在边缘,k=100 时只剩噪声
图 3 残差 \(|A-A_k|\)(为了看得见,整体放大 3 倍)。丢掉的不是「随机噪声」,而是边缘——奇异值小的方向恰好承载了高频信息。
k存储能量相对误差RMSE
52.92%99.0149%0.099316.25
105.84%99.7425%0.05078.31
2011.68%99.8988%0.03185.21
5029.21%99.9748%0.01592.60
10058.42%99.9935%0.00801.32

列义:存储 = \(k(m+n+1)/(mn)\),即存 \(U_k,\Sigma_k,V_k^\top\) 的元素数占原矩阵的比例;能量 = \(\sum_{j\le k}\sigma_j^2/\sum_j\sigma_j^2\);相对误差 = \(\lVert A-A_k\rVert_F/\lVert A\rVert_F\);RMSE 是逐像素差(灰度 0–255)。

这张图的矩阵是满秩的(\(\operatorname{rank}=300\)),但 \(\sigma_1\approx5.30\times10^4\)、\(\sigma_{100}\approx54.6\)、\(\sigma_{300}\approx6.3\),跨了四个数量级。图 4 是它的奇异值谱和累积能量:

奇异值对数谱与累积能量曲线,k=5 已占 99.01% 能量
图 4 上:\(\log_{10}\sigma_k\)(横轴对数刻度)。下:累积能量占比。\(k=5\) 已 99.01%、\(k=20\) 达 99.90%。谱线在 \(k\approx78\) 有一处明显的陡降(\(\sigma_{78}\approx96.5\to\sigma_{79}\) 掉了 0.17 个数量级),说明那里有一组成分的能量被吃尽;但抬头看纵轴,绝对值早已在 \(10^2\) 以下,对重建几乎没有贡献。
这张表最容易被误读的一行

\(k=5\) 的「保留能量 99.01%」看着很好,但相对 Frobenius 误差还有 0.0993、RMSE 16.25,图 2 里硬边已经是明显的波纹。原因见 15.9 的推论:相对误差的平方 = 丢掉的能量占比,\(\sqrt{0.0099}\approx0.0995\)。能量到小数点后三位才开始「没事」,所以别用 99.9% 这种写法掩盖 10% 的误差。

这也是「满秩 ≠ 高维」的含义:秩是精确概念,有效维度看奇异值的衰减。真实图像、词–文档矩阵、注意力权重矩阵都有这种长尾。

为什么自然图像衰减得快?因为像素之间有强相关性:相邻点的灰度几乎一样,大块区域是近似常量。这种「低秩 + 局部平滑」结构让少数几个正交方向就能解释绝大多数变化;剩下的小奇异值对应高频细节。要注意的是高频 ≠ 噪声:图 3 里 \(k=5\) 的残差几乎全是硬边轮廓,而 \(k=100\) 的残差才接近随机噪声。所以「截断能去噪」只在信号本身低频、噪声本身高频时才成立。

别把「能量」当「画质」

Frobenius 误差小不代表看起来没问题(见 15.10)。视觉任务更常看 SSIM / 人眼判断,能量只是一个先验指标。

15.11 代码:NumPy 与 scikit-learn

手算部分用 NumPy 复核(对应 15.8 的例子):

import numpy as np

A = np.array([[1., 0.],
              [0., 1.],
              [2., 2.]])

# reduced/economy 形式:U 是 3x2,Vt 是 2x2
U, S, Vt = np.linalg.svd(A,
                         full_matrices=False)
print(S)                        # [3. 1.]
print(np.allclose(U * S @ Vt, A))   # True

# 秩 1 近似
k = 1
A1 = (U[:, :k] * S[:k]) @ Vt[:k]
print(np.round(A1, 3))   # 0.5/0.5/2 一行
print(np.linalg.norm(A - A1))   # 1.0 == S[1]

要点:NumPy 的 S 是一维奇异值数组(已经降序),不是对角矩阵,要自己 np.diag。用 U[:, :k] * S[:k] 做广播比显式造对角矩阵省一次矩阵乘。

15.10 的图就是这个矩阵的产物。它就是「四个硬边色块 + 一条细条纹 + 轻噪声」,没有任何外部图片,直接由代码生成,可以自己复现:

import numpy as np

rng = np.random.default_rng(0)
h, w = 400, 300
y = np.linspace(0, 1, h)[:, None]
x = np.linspace(0, 1, w)[None, :]

def box(x0, x1, y0, y1):
    return ((x > x0) & (x < x1) &
            (y > y0) & (y < y1))

img = np.full((h, w), 225.0)
img[box(.05, .45, .08, .42)] = 45    # 暗块
img[box(.50, .95, .15, .55)] = 70    # 灰块
img[box(.55, .90, .65, .95)] = 200   # 亮块

ell = (((x - .25) / .18) ** 2 +
       ((y - .72) / .20) ** 2 < 1)
img[ell] = 30                        # 椭圆

a = 90 * np.pi * x
b = 24 * np.pi * y
stripe = (x > .42) & (x < .50)
wave = 120 + 70 * np.sin(a) * np.cos(b)
img = np.where(stripe, wave, img)    # 条纹

noise = rng.normal(0, 2, (h, w))
img = np.clip(img + noise, 0, 255)

拿到任意 \(m\times n\) 灰度矩阵后,下面这段算出 15.10 那张表的每一列(plt.imread 换成自己的图即可):

import numpy as np
import matplotlib.pyplot as plt

img = plt.imread('image.png')  # h,w[,3]
h, w = img.shape[:2]

def compress(channel, k):
    U, S, Vt = np.linalg.svd(
        channel, full_matrices=False)
    return (U[:, :k] * S[:k]) @ Vt[:k]

U, S, Vt = np.linalg.svd(img,
                         full_matrices=False)
energy = np.cumsum(S ** 2) / (S ** 2).sum()

for k in (5, 10, 20, 50, 100):
    if img.ndim == 3:
        chans = [compress(img[:, :, c], k)
                 for c in range(3)]
        rec = np.stack(chans, axis=2)
    else:
        rec = compress(img, k)
    rel = (np.linalg.norm(img - rec) /
           np.linalg.norm(img))
    rmse = np.sqrt(np.mean((rec - img) ** 2))
    keep = k * (h + w + 1) / (h * w)
    print(f'{k:3d} {keep:6.2%} '
          f'{rel:7.4f} {rmse:6.2f}')

存储为什么是 \(k(m+n+1)\)?需要存 \(m\times k\) 的 \(U_k\)、\(k\) 个数、\(k\times n\) 的 \(V_k^\top\)。分母 \(mn\) 是原始矩阵的元素个数。\(k\) 小的时候收益巨大,\(k\to\min(m,n)\) 时反而比原矩阵更大——压缩只在低秩时成立。

scikit-learn 的 TruncatedSVD 直接用截断版求解器,并且总是返回「降维后的数据」\(X V_k\)。注意它要求 n_components < n_features:下面这个 \(A\) 只有 2 列,所以 \(k\) 只能取 1(取 2 会直接抛 ValueError):

from sklearn.decomposition import (
    TruncatedSVD)

svd = TruncatedSVD(n_components=1,
                   random_state=0)
Z = svd.fit_transform(A)   # (m,k)=U_k*Sigma_k
R = svd.inverse_transform(Z)
print(svd.singular_values_)   # [3.]
print(svd.components_.shape)  # (1, 2)

algorithm='randomized' 是默认值(Halko–Martinsson–Tropp 的随机化算法),对稀疏、超大的矩阵比精确 SVD 快得多;'arpack' 走 ARPACK 迭代,适合只想算前若干个奇异值的情形。

TruncatedSVD 的中心化:它不做均值中心化(这正是它和 PCA 的实现差别)。做 PCA 请用 sklearn.decomposition.PCA,那里会先减均值。

15.12 和 PCA 是同一件事

设数据矩阵 \(X\in\mathbb{R}^{n\times p}\):\(n\) 个样本、\(p\) 个特征(每行一个样本,通常已中心化)。它的 SVD 是 \(X=U\Sigma V^\top\)。协方差矩阵(差一个 \(1/(n-1)\) 的常数)是:

\[ \begin{aligned} X^\top X&=(U\Sigma V^\top)^\top(U\Sigma V^\top)\\ &=V\Sigma^\top U^\top U\Sigma V^\top =V\Sigma^2V^\top . \end{aligned} \]

也就是说 \(X^\top X\) 的特征分解就是 \(V\Sigma^2V^\top\):右奇异向量 \(V\) 就是主成分方向,\(\sigma_j^2\) 正比于第 \(j\) 个主成分的方差。投影到前 \(k\) 个主成分上:

\[ Z=XV_k=U_k\Sigma_k\quad(\text{因为 }XV=U\Sigma). \]

两种写法等价,但右边只要乘 \(m\times k\) 与 \(k\times k\),比左边乘 \(m\times n\) 与 \(n\times k\) 便宜——这就是 15.11 里 fit_transform 直接返回 \(U_k\Sigma_k\) 的原因。

新样本 \(\mathbf{x}\)(列向量)要投影到同一子空间:\(\mathbf{z}=V_k^\top\mathbf{x}\)。注意转置方向:数据按行存时是 \(XV_k\),按列存时是 \(V_k^\top\mathbf{x}\)。

中心化不能省

上面这个等价关系要求 \(X\) 已经按列减过均值。否则 \(X^\top X\) 里混进了均值的外积,第一主成分会退化成「均值方向」——一张全体偏亮的图,第一主成分就是亮度本身。这也解释了 15.11 里两个 API 的差别:PCA 会先中心化,TruncatedSVD 不会(它严格来说叫未中心化的 PCA / LSA)。稀疏矩阵不能随便减均值(会把 0 变成非 0,稀疏性消失),所以文本场景常用 TruncatedSVD 而不是 PCA。

15.13 用 SVD 解方程、找零空间

SVD 最实用的副产品是伪逆:

\[ A^{+}=V\Sigma^{+}U^\top,\qquad \Sigma^{+}_{jj}=\begin{cases}1/\sigma_j,&\sigma_j>0,\\0,&\sigma_j=0.\end{cases} \]

\(A^+\mathbf{b}\) 给出最小二乘解,并且在解不唯一时给出范数最小的那个。\(A\) 可逆时 \(A^+=A^{-1}\);\(A\) 不是方阵也能用。NumPy 里 np.linalg.pinv(A) @ b 就是它。

看一个具体例子。三个人分气瓶的问题化成一对方程:

\[ 19J-20D=0,\qquad 6J-15D+1650=0, \]

写成 \(A\mathbf{w}=0\),其中 \(\mathbf{w}=(J,D,1)^\top\):

\[ A=\begin{bmatrix}19&-20&0\\6&-15&1650\end{bmatrix}. \]

这是个 \(2\times3\) 矩阵,\(\operatorname{rank}A=2\)(\(\sigma_1\approx1650.08,\ \sigma_2\approx27.59\)),所以零空间是 1 维。SVD 的第三行 \(V^\top\) 就是零空间的基:

\[ \mathbf{v}_3\approx\begin{bmatrix}0.724995\\0.688745\\0.003625\end{bmatrix}, \qquad \frac{\mathbf{v}_3}{\min(\mathbf{v}_3)}=\begin{bmatrix}200\\190\\1\end{bmatrix}, \] \[ \Longrightarrow\quad J=200,\ D=190 . \]

验算:\(19\cdot200-20\cdot190=3800-3800=0\),\(6\cdot200-15\cdot190+1650=1200-2850+1650=0\)。

这件事的价值在于:零空间的基向量直接就是齐次方程的解,不用做消元、不用选自由变量、不用回代。把最后一维归一化成 1,就得到原方程组的解。代价是 SVD 比高斯消元贵(\(O(mn^2)\) 对 \(O(n^3/3)\),量级相近但常数大),所以只在「需要稳健性」或「需要低秩结构」时才这么做。

为什么对病态问题更稳

\(\kappa(A)=\sigma_1/\sigma_n\) 决定了线性方程组解对扰动的放大倍数。\(\sigma_n\) 很小意味着矩阵「几乎把某个方向压成 0」,此时求逆等于把误差放大 \(1/\sigma_n\) 倍。用 \(\Sigma^+\) 时显式把 \(\sigma_j\) 低于阈值的方向置 0(截断伪逆),本质上就是「宁可丢掉这个方向,也不要把噪声放大」。岭回归、Tikhonov 正则化都是这件事的连续版本。

15.14 最小二乘的几何:投影到列空间

上一节的例子是一个 \(2\times3\) 的欠定齐次系统;最后一列常数项把原来的非齐次方程改写成了齐次方程,因此出现了一维零空间。更有代表性的是 \(A\mathbf{x}=\mathbf{b}\) 无解的情形:\(m\gt n\) 时方程的个数多于未知数,\(\mathbf{b}\) 一般不在 \(A\) 的列空间里。

此时我们退一步,找让残差最小的 \(\mathbf{x}\):

\[ \min_{\mathbf{x}}\ \lVert A\mathbf{x}-\mathbf{b}\rVert_2 . \]

几何上答案很直接:把 \(\mathbf{b}\) 正交投影到 \(A\) 的列空间,再在列空间里精确求解。列空间的正交基是 \(\{\mathbf{u}_1,\dots,\mathbf{u}_r\}\),所以投影矩阵是

\[ P=U_rU_r^\top,\qquad P\mathbf{b}=\sum_{j=1}^{r}(\mathbf{u}_j^\top\mathbf{b})\,\mathbf{u}_j . \]

残差就是被投影掉的部分,它只落在 \(\{\mathbf{u}_{r+1},\dots,\mathbf{u}_m\}\) 张成的左零空间里:

\[ \mathbf{b}-A\mathbf{x}_\star=\sum_{j=r+1}^{m}(\mathbf{u}_j^\top\mathbf{b})\,\mathbf{u}_j, \] \[ \lVert\mathbf{b}-A\mathbf{x}_\star\rVert_2^2 =\sum_{j>r}(\mathbf{u}_j^\top\mathbf{b})^2 . \]

把 \(A=U\Sigma V^\top\) 代进伪逆就得到解,全过程没有求逆矩阵:

\[ \begin{aligned} \mathbf{x}_\star&=A^{+}\mathbf{b}=V_r\Sigma_r^{-1}U_r^\top\mathbf{b}\\ &=\sum_{j=1}^{r}\frac{\mathbf{u}_j^\top\mathbf{b}}{\sigma_j}\,\mathbf{v}_j . \end{aligned} \]

顺便验证它和法方程 \(A^\top A\mathbf{x}=A^\top\mathbf{b}\) 是同一件事:\(A^\top A=V\Sigma^\top\Sigma V^\top=V\Sigma^2V^\top\)(满列秩时),代进去

\[ V\Sigma^2V^\top\mathbf{x}=V\Sigma U^\top\mathbf{b} \] \[ \Longrightarrow\quad \mathbf{x}=V\Sigma^{-2}V^\top V\Sigma U^\top\mathbf{b} =V\Sigma^{-1}U^\top\mathbf{b}. \]

式子一模一样,但做法完全不同:法方程要算 \(A^\top A\)(把条件数平方,见 15.7 的警告),SVD 路线不需要。

拿 15.8 的 \(A=\begin{bmatrix}1&0\\0&1\\2&2\end{bmatrix}\) 配 \(\mathbf{b}=(1,1,0)^\top\) 算一遍。\(r=2\),残差只可能在 \(\mathbf{u}_3\) 方向。三个投影系数:

\[ \begin{aligned} \mathbf{u}_1^\top\mathbf{b}&=\frac{1+1+0}{\sqrt{18}}=\frac{\sqrt2}{3}\approx0.4714,\\ \mathbf{u}_2^\top\mathbf{b}&=\frac{1-1+0}{\sqrt2}=0 . \end{aligned} \]

因为 \(\mathbf{u}_2^\top\mathbf{b}=0\),解里 \(\mathbf{v}_2\) 方向的系数是 0,于是

\[ \begin{aligned} \mathbf{x}_\star&=\frac{0.4714}{3}\,\mathbf{v}_1 =\frac{0.4714}{3}\cdot\frac{1}{\sqrt2}\begin{bmatrix}1\\1\end{bmatrix}\\ &=\frac{1}{9}\begin{bmatrix}1\\1\end{bmatrix} \approx\begin{bmatrix}0.1111\\0.1111\end{bmatrix}. \end{aligned} \]

投影点 \(A\mathbf{x}_\star=(0.1111,\ 0.1111,\ 0.4444)^\top\),残差 \(\mathbf{b}-A\mathbf{x}_\star=(0.8889,\ 0.8889,\ -0.4444)^\top\),长度 \(4/3\approx1.333\)。用谱检一下:\(\mathbf{u}_3^\top\mathbf{b}=\frac{-2-2+0}{3}=-\frac43\),残差 \(=(\mathbf{u}_3^\top\mathbf{b})\mathbf{u}_3=\frac{8}{9}(1,1,-\frac12)^\top\),与上面的向量一致。

记住这个图像

最小二乘 = 投影。能不能精确求解看 \(\mathbf{b}\) 是否落在 \(\{\mathbf{u}_1,\dots,\mathbf{u}_r\}\) 里;解稳不稳看 \(\sigma_r\)(越小,\(\mathbf{v}_j/\sigma_j\) 这一项越容易被 \(\mathbf{b}\) 的扰动带跑)。

15.15 低秩参数化:LoRA 的账

上面所有内容都是「先有一个矩阵,再把它压成低秩」。工程上更常见的是反过来:一开始就只允许矩阵是低秩的。

以微调一个 \(1024\times1024\) 的线性层为例(Qwen3-0.6B 的隐藏维度就是 1024)。全量微调要更新

\[ mn=1024\times1024=1{,}048{,}576\ \text{个参数}. \]

LoRA 改成 \(W'=W+BA\),\(B\in\mathbb{R}^{m\times r}\)、\(A\in\mathbb{R}^{r\times n}\),只训练 \(B,A\):

\[ mr+rn=r(m+n). \]

取 \(r=8\):\(8\times2048=16{,}384\),是原来的 \(1.56\%\);取 \(r=16\):\(32{,}768\),\(3.13\%\)。前向计算量也相应变成 \(r(m+n)\) 次乘加而不是 \(mn\)。

为什么这么小的 \(r\) 够用?因为假设「微调带来的增量 \(\Delta W\) 近似低秩」。这个假设可以用 SVD 直接检验:先全量微调一个小模型,对 \(\Delta W\) 做 SVD,看前 \(r\) 个奇异值占多少能量(就是 15.9 的累计能量曲线)。如果 \(\sum_{j\le r}\sigma_j^2/\sum_j\sigma_j^2\) 已经 95% 以上,说明秩 \(r\) 就够。

截断 SVD ≠ LoRA

两者共用「低秩」这个假设,但优化目标不同:截断 SVD 最小化 \(\lVert\Delta W-BA\rVert_F\),有 Eckart–Young 保证它是最优的;LoRA 最小化的是任务损失,训练出来的 \(BA\) 通常不是 \(\Delta W\) 的最优秩 \(r\) 近似。所以「用 SVD 分解出 \(A,B\) 当 LoRA 初值」只是巧妙的初始化,不是等价变换。

还有一点容易忽略:\(B\) 通常初始化为 0(或 \(A\) 用高斯、\(B\) 用 0),这样训练开始时 \(BA=0\),模型行为与原模型完全一致。若改成随机初始化,第一步就会破坏预训练权重。

15.16 数值秩与随机化 SVD

浮点里几乎没有矩阵是「精确低秩」的:一张 400×300 的图,第 300 个奇异值也是 4.2 而不是 0。所以「秩」在工程上要换成带阈值的版本:

\[ \operatorname{rank}_\tau A=\#\{j:\sigma_j\gt\tau\}, \] \[ \tau=\sigma_1\cdot\max(m,n)\cdot\epsilon_{\text{mach}} . \]

\(\epsilon_{\text{mach}}\) 是浮点精度(float64 约 \(2.2\times10^{-16}\),float32 约 \(1.2\times10^{-7}\))。乘 \(\max(m,n)\) 是因为 \(A^\top A\) 里的求和项数随维度增长,舍入误差会累积。np.linalg.matrix_rank 的默认容差就是这个式子。

另一个现实问题是大。精确 SVD 的复杂度是 \(O(mn\min(m,n))\),\(10^5\times10^5\) 的矩阵根本做不完,也放不进内存。随机化 SVD(Halko–Martinsson–Tropp)只用三次矩阵乘就能拿到前 \(k\) 个奇异三元组:

  1. 取随机高斯矩阵 \(\Omega\in\mathbb{R}^{n\times(k+p)}\)(\(p\approx5\sim10\) 是过采样量),计算 \(\mathbf{Y}=A\Omega\)。\(\mathbf{Y}\) 的列几乎必然落在 \(A\) 的前 \(k+p\) 个左奇异向量张成的子空间里。
  2. 对 \(\mathbf{Y}\) 做 QR 分解得 \(Q\in\mathbb{R}^{m\times(k+p)}\),它的列是这个子空间的正交基,这就是「抓到了主要方向」。
  3. 把 \(A\) 投影到这个子空间:\(B=Q^\top A\in\mathbb{R}^{(k+p)\times n}\)。\(B\) 很小,对它做精确 SVD 得 \(B=\hat U\Sigma V^\top\),于是 \(U\approx Q\hat U\)。

如果奇异值衰减不够快(\(\sigma_{k+1}\) 和 \(\sigma_1\) 差不多),第 1 步抓到的子空间就不准,这时插入若干次幂迭代——把 \(\mathbf{Y}\) 换成 \((AA^\top)^qA\Omega\),让大奇异值相对放大 \(({\sigma_j}/{\sigma_{j'}})^{2q}\) 倍。这正是 TruncatedSVD(algorithm='randomized') 背后的算法。

随机化 SVD 的误差是概率意义下的:以极大概率(由 \(p\) 决定)满足 \(\lVert A-U_k\Sigma_kV_k^\top\rVert\le(1+\varepsilon)\lVert A-A_k\rVert\),\(\varepsilon\) 随 \(p\) 增大而减小。它是「几乎最优」而不是「Eckart–Young 意义下的恰好最优」。

15.17 三个常见误解

①「秩小的矩阵一定简单、元素一定小」。秩只说明列之间有没有线性关系,和数值大小无关。\(A=10^6\begin{bmatrix}1&0\\0&0\end{bmatrix}\) 的秩是 1,谱范数是 \(10^6\)。反过来,元素都在 \([0,1]\) 里的随机矩阵往往是满秩的。判断「简单」要看奇异值谱,不是看秩这个数字。

②「奇异值就是特征值的绝对值」。这只对对称(更一般地,正规)矩阵成立。反例:

\[ A=\begin{bmatrix}0&1\\0&0\end{bmatrix}, \qquad \det(A-\lambda I)=\lambda^2, \] \[ \Rightarrow\quad \lambda_1=\lambda_2=0, \]

但 \(A^\top A=\begin{bmatrix}0&0\\0&1\end{bmatrix}\),所以 \(\sigma_1=1,\sigma_2=0\)。特征值全 0 的矩阵照样能把某个向量拉长 1 倍——它只是「转完就压扁」。

③「截断 SVD 丢掉的都是噪声」。丢掉的是「能量小的方向」,而能量小不等于噪声:一张全体偏亮的照片,最亮的那个方向(均值)恰好是最大的奇异值方向;细纹理反过来可能落在很小的奇异值上。PCA 去噪成立的前提是「信号能量集中在少数方向」——这是关于数据分布的假设,需要被检验,不是 SVD 自带的性质。同理,去掉最大的几个分量也常被用来「去背景」,方向相反但道理一样。

15.18 应用与局限

按「数据长什么样」分类,SVD 的用法其实只有几条:

局限也要说清楚:

15.19 小结

对应代码:本仓库的 Go 实现里没有直接的 SVD 调用。相关的是 model/ 下的权重加载与 LoRA 分支——那里用到的是「低秩参数化」这条假设,而不是显式分解。要在 Go 里做 SVD,用 gonum.org/v1/gonum/mat 的 mat.SVD(底层就是 LAPACK 的 gesvd),不要自己构造 \(A^\top A\)。