首页 / 模块 1 · 线性代数与微积分 / 第 5 课(共 10 课)

SVD 与低秩压缩

从零到前沿 ML 自学课程 · 阶段0:数学与工具基础 · 能力点:SVD——PCA 与 LoRA 低秩微调的共同数学根基

读完这一课,你将能够

  • 对任意 \(m\times n\) 矩阵说出 \(U,\Sigma,V\) 各自的形状,并解释 \(\Sigma\) 为什么是长方形的。
  • 用"圆 → 旋转 → 缩放成椭圆 → 旋转"复述 SVD 的几何含义,并指出椭圆半轴就是奇异值 \(\sigma_i\)。
  • 写出秩-k 截断 \(A_k=\sum_{i\le k}\sigma_i u_i v_i^\top\),并用 Eckart–Young 说出逼近误差 \(=\sqrt{\sum_{i>k}\sigma_i^2}\)。
  • 由 \(\sigma_i^2=\lambda_i(A^\top A)\) 解释为什么奇异值实且非负、SVD 对任意矩阵都存在。
  • 用一句话讲清 LoRA 为何省参数,并指出它与"秩-k 截断"的关系(事前约束 vs 事后压缩)。

1. 开场:一个能拆开任何矩阵的"瑞士军刀"

上一课我们学了特征分解 eigendecomposition:把一个方阵拆成"换基 → 沿轴缩放 → 换回来",看清了它在自己特征方向上的拉伸倍率。但特征分解有两个硬伤:它只对方阵有定义,而且即便是方阵也不保证能分解(缺特征向量时会失败)。可现实里的矩阵大多是长方形的——一个 \(1000\) 张图片 × \(784\) 像素的数据矩阵,一个 \(50000\) 词 × \(768\) 维的词嵌入表,它们根本不是方阵。

这一课的主角奇异值分解 SVD(Singular Value Decomposition)补上了这块拼图:任意 \(m\times n\) 的实矩阵 \(A\),无论方不方、满不满秩,都能写成

\[ A = U\,\Sigma\,V^\top. \]

它是线性代数里最实用、最"万能"的分解,没有之一。Gilbert Strang 干脆称它为线性代数的"最终定理"。学完这一课你会发现:图像压缩、PCA 降维、推荐系统、数据白化,乃至 2021 年后席卷大模型微调的 LoRA,背后都是同一句话——一个大矩阵,常常能用一个低秩矩阵很好地近似。而 SVD 正是把这句话变成精确数学的工具。

一句话先记住:SVD 把任何线性变换都拆成三个最朴素的动作——先旋转,再沿坐标轴缩放,最后又旋转。下一节我们就把这三步看个明白。

2. 几何直觉:圆怎么被压成椭圆

回忆第 2 课的核心观点:矩阵 \(A\) 就是一个线性变换,它把空间里的每个点搬到新位置。问一个朴素的问题:如果我把一个单位圆(所有长度为 1 的向量的箭头尖)丢给 \(A\),它会变成什么形状?

答案永远是一个椭圆(高维里是椭球)。这不是巧合,而是线性变换的铁律。SVD 干的事,就是把"圆 → 椭圆"这个过程拆成三个干净的步骤:

  1. \(V^\top\):旋转。先把空间转一下,找到一组特殊的、互相垂直的"输入方向"(即 \(V\) 的列),把它们对齐到坐标轴。旋转不改变任何长度和角度,圆还是圆。
  2. \(\Sigma\):沿坐标轴缩放。\(\Sigma\) 是对角矩阵,第 \(i\) 个对角元是 \(\sigma_i\)。它把第 \(i\) 根坐标轴方向拉伸 \(\sigma_i\) 倍。于是圆被压成了一个正椭圆,半轴长正好是 \(\sigma_1, \sigma_2, \dots\)。
  3. \(U\):再旋转。把这个正椭圆整体转到它在输出空间里真正的朝向。
SVD 的三步几何分解:旋转 → 轴向缩放 → 旋转A = U Σ Vᵀ:旋转 → 缩放 → 旋转v₁v₂单位圆(输入基)形状:圆Vᵀ 旋转v₁v₂基已转正(对齐坐标轴)形状:圆Σ 缩放σ₁σ₂正椭圆(半轴 σ₁、σ₂)形状:正椭圆U 旋转u₁u₂斜椭圆(输出主方向)形状:斜椭圆
SVD 的三步几何:单位圆经 Vᵀ 旋转(仍是圆)→ 经 Σ 沿坐标轴缩放(压成半轴为 σ₁、σ₂ 的正椭圆)→ 经 U 旋转到输出空间的最终朝向。展示 A=UΣVᵀ 把任意线性变换拆成‘旋转-轴向缩放-旋转’。

"旋转"是个通俗说法。严格来说 \(V^\top\)、\(U\) 是正交变换,行列式可能是 \(-1\),也就是说里头可能含一次反射(照镜子般翻个面)。但无论是不是纯旋转,正交变换都不改变长度与角度,所以圆仍然是圆——本课为了直觉一律说成"旋转",记住它泛指"不变形的刚性变换"即可。

所以"换基 → 缩放 → 换回来"这套话术,特征分解只能在方阵上玩、而且进出用的是同一组(可能斜的)基;SVD 则更慷慨:它允许输入用一组正交基 \(V\)、输出用另一组正交基 \(U\),中间只剩纯粹的轴向缩放。正因为输入输出可以用两组不同的基,长方形矩阵也照拆不误。

要点

  • \(V=[v_1,\dots,v_n]\) 的列叫右奇异向量 right singular vectors,是一组正交的"输入主方向"。
  • \(U=[u_1,\dots,u_m]\) 的列叫左奇异向量 left singular vectors,是一组正交的"输出主方向"。
  • \(\sigma_1\ge\sigma_2\ge\dots\ge 0\) 叫奇异值 singular values,是每个方向上的增益(放大倍率)。约定从大到小排列。
  • 关系式 \(Av_i=\sigma_i u_i\):把第 \(i\) 个输入主方向喂进去,输出正好是第 \(i\) 个输出主方向、被放大 \(\sigma_i\) 倍。这是 SVD 全部直觉的一行总结。

3. 形状与正交性:把记号钉死

"任意 \(m\times n\)"听起来抽象,我们把三个因子的形状写死,你就再也不会搞混:

因子形状是什么
\(U\)\(m\times m\)正交矩阵,列两两正交且单位长,\(U^\top U=I_m\)(纯旋转/反射)
\(\Sigma\)\(m\times n\)对角阵(长方形!),对角线上是 \(\sigma_1\ge\dots\ge\sigma_r>0\),其余全 0
\(V\)\(n\times n\)正交矩阵,\(V^\top V=I_n\);注意分解里用的是它的转置 \(V^\top\)

这里 \(r\) 是矩阵的秩 rank,即非零奇异值的个数。维度上一对就懂了:\((m\times m)(m\times n)(n\times n)=(m\times n)\),正好还原成 \(A\)。

满版 vs. 经济版。上面是满版 full SVD。但 \(\Sigma\) 的长方形里塞了一大堆 0 行或 0 列,纯属浪费。实践中常用经济版 / 瘦版 thin SVD:只保留前 \(r\)(或前 \(k\))个奇异向量,\(U\) 取 \(m\times r\)、\(\Sigma\) 取 \(r\times r\)、\(V\) 取 \(n\times r\)。NumPy 里 np.linalg.svd(A, full_matrices=False) 给的就是经济版。两者描述的是同一个 \(A\),只是要不要带上那些"被压成 0、其实没用"的方向。

4. SVD 与特征分解:它们其实是亲戚

SVD 不是凭空冒出来的。它和上一课的特征分解有一条精确的血缘关系,而且这条关系还顺手给出了"SVD 为什么一定存在"的理由。

把 \(A=U\Sigma V^\top\) 代进 \(A^\top A\):

\[ 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. \]

中间用了 \(U^\top U=I\)。注意 \(\Sigma^\top\Sigma\) 是个 \(n\times n\) 的对角阵,对角元是 \(\sigma_i^2\)。这一行正好是 \(A^\top A\) 的特征分解!同理可得 \(AA^\top = U(\Sigma\Sigma^\top)U^\top\)。于是:

要点:SVD = 两个对称矩阵的特征分解拼起来

  • \(V\) 的列 = \(A^\top A\)(\(n\times n\))的特征向量;
  • \(U\) 的列 = \(AA^\top\)(\(m\times m\))的特征向量;
  • 奇异值的平方 = 特征值:\(\sigma_i^2=\lambda_i\),所以 \(\sigma_i=\sqrt{\lambda_i}\)。

因为 \(A^\top A\) 永远是对称半正定矩阵(任意 \(x\),\(x^\top A^\top A x=\|Ax\|^2\ge 0\)),它一定有一组正交特征向量、且特征值非负——这就保证了奇异值是实的、非负的,SVD 对任何矩阵都存在。这正是 SVD 比特征分解"万能"的根源。

那些奇异值为 0 的方向呢?当 \(r<\min(m,n)\) 时,关系式 \(Av_i=\sigma_i u_i\) 只能配出前 \(r\) 对奇异向量(\(\sigma_i>0\) 的那些)。\(U\)、\(V\) 里剩下的列——对应奇异值为 0——并不由这条关系唯一确定,而是直接取 \(AA^\top\)、\(A^\top A\) 的零特征值的特征向量补齐(也就是下面要说的左零空间、零空间的正交基)。它们的作用只是把 \(U\)、\(V\) 凑满成完整的正交矩阵。

易错

别把奇异值和特征值划等号。奇异值 \(\sigma_i\ge 0\) 永远非负;而方阵的特征值可以是负数甚至复数。只有当 \(A\) 本身是对称半正定方阵时,两者才相等。一般情况下是 \(\sigma_i^2=\lambda_i(A^\top A)\) 这条间接关系,注意是对 \(A^\top A\) 求特征值,不是对 \(A\)。

顺带一提:方向自动分成两拨

SVD 有个漂亮的副产品:它把奇异向量天然分成了"有用的"和"被压扁的"两拨。

这种"非零方向 vs. 零方向"的二分,其实正对应线性代数里 \(A\) 的四个基本子空间(列空间、行空间、零空间、左零空间)。这套理论以后会在专门的线代专题里细讲,这里你只需感觉到:SVD 自动把所有方向分成了"通得过"和"通不过"两堆,就够了。

5. 例题:亲手拆一个 2×3 矩阵

例题

对矩阵 \(A=\begin{bmatrix}3&2&2\\2&3&-2\end{bmatrix}\)(\(m=2, n=3\))做 SVD,并解释结果。

第一步,看形状。\(A\) 是 \(2\times3\),所以 \(U\) 是 \(2\times2\),\(\Sigma\) 是 \(2\times3\),\(V\) 是 \(3\times3\)。最多有 \(\min(2,3)=2\) 个非零奇异值。

第二步,借 \(AA^\top\) 求奇异值。取 \(2\times2\) 的 \(AA^\top\) 更省事:

\[ AA^\top=\begin{bmatrix}3&2&2\\2&3&-2\end{bmatrix}\begin{bmatrix}3&2\\2&3\\2&-2\end{bmatrix}=\begin{bmatrix}17&8\\8&17\end{bmatrix}. \]

它的特征值满足 \((17-\lambda)^2-8^2=0\),即 \(17-\lambda=\pm 8\),得 \(\lambda=25\) 或 \(\lambda=9\)。于是奇异值

\[ \sigma_1=\sqrt{25}=5,\qquad \sigma_2=\sqrt{9}=3. \]

第三步,求奇异向量。\(AA^\top\) 的特征向量给出 \(U\):对称矩阵 \(\begin{bmatrix}17&8\\8&17\end{bmatrix}\) 的特征向量正是 \(\tfrac{1}{\sqrt2}[1,1]^\top\)(对应 25)和 \(\tfrac{1}{\sqrt2}[1,-1]^\top\)(对应 9),它们就是 \(u_1,u_2\)。

接着用关系式 \(v_i=A^\top u_i/\sigma_i\) 反推 \(V\) 的前两列。以 \(v_1\) 为例,亲手算一遍看公式怎么落地:

\[ v_1=\frac{A^\top u_1}{\sigma_1}=\frac{1}{5}\begin{bmatrix}3&2\\2&3\\2&-2\end{bmatrix}\cdot\frac{1}{\sqrt2}\begin{bmatrix}1\\1\end{bmatrix}=\frac{1}{5\sqrt2}\begin{bmatrix}5\\5\\0\end{bmatrix}=\frac{1}{\sqrt2}\begin{bmatrix}1\\1\\0\end{bmatrix}. \]

正好是个单位向量,验证无误(\(v_2\) 同理可得)。

第三个右奇异向量 \(v_3\) 从哪来?注意它不能由 \(v_i=A^\top u_i/\sigma_i\) 算出——因为 \(\sigma_3=0\),根本没有对应的 \(u_3\) 可用,公式分母会是 0。\(v_3\) 是与 \(v_1,v_2\) 都正交的那个单位向量(也就是 \(A^\top A\) 对应特征值 0 的特征向量),它正是被 \(A\) 压到原点的零空间方向。这就是长方形矩阵"多出一个零空间方向"的来历——也是第 4 节那个"信息黑洞"的具体落地。最终(数值见下方代码,符号可能整体取反,这是 SVD 的正常自由度):

\[ \Sigma=\begin{bmatrix}5&0&0\\0&3&0\end{bmatrix},\quad U=\tfrac{1}{\sqrt2}\begin{bmatrix}1&1\\1&-1\end{bmatrix}. \]

读数。这个变换把单位圆压成一个半轴为 5 和 3 的椭圆;第三个右奇异向量 \(v_3\)(\(\sigma_3=0\))就是 \(A\) 的零空间方向——存在一个非零向量被 \(A\) 直接打到原点。

import numpy as np
np.set_printoptions(precision=4, suppress=True)

A = np.array([[3., 2., 2.],
              [2., 3., -2.]])
U, s, Vt = np.linalg.svd(A, full_matrices=True)   # 注意返回的是 s(向量) 和 V 的转置 Vt
print("U =\n", U)            # 2x2
print("奇异值 s =", s)        # [5. 3.]
print("Vt =\n", Vt)          # 3x3, 它的"行"是右奇异向量
print("形状:", U.shape, s.shape, Vt.shape)

# 用 s 拼出 2x3 的 Sigma 再乘回去,验证能还原 A
Sigma = np.zeros((2, 3))
Sigma[:2, :2] = np.diag(s)
A_rebuilt = U @ Sigma @ Vt
print("还原误差 =", np.linalg.norm(A - A_rebuilt))   # ~1e-15,基本为 0

6. 秩-k 截断:低秩压缩的核心

现在来到这一课的"题眼"。把 \(A=U\Sigma V^\top\) 按奇异值逐项展开,会得到一个极其漂亮的写法——矩阵等于一堆外积 outer product 之和:

\[ A=\sum_{i=1}^{r}\sigma_i\, u_i v_i^\top = \sigma_1 u_1 v_1^\top+\sigma_2 u_2 v_2^\top+\dots+\sigma_r u_r v_r^\top. \]

每一项 \(\sigma_i u_i v_i^\top\) 都是一个秩-1 矩阵(一列乘一行,铺成一整块 \(m\times n\)),重要性由 \(\sigma_i\) 衡量。由于奇异值从大到小排列,前几项装着 \(A\) 的绝大部分"能量",后面的项越来越微不足道

"能量"是什么?这个词后面会反复出现,它有精确含义,不是物理隐喻:第 \(i\) 个方向贡献的能量就是 \(\sigma_i^2\)。整个矩阵的总能量 = \(\sum_i\sigma_i^2 = \|A\|_F^2\)(即 Frobenius 范数的平方,把所有元素平方求和)。于是"前 \(k\) 项占的能量比"就是

\[ \frac{\sigma_1^2+\sigma_2^2+\dots+\sigma_k^2}{\sigma_1^2+\sigma_2^2+\dots+\sigma_r^2}. \]

后面说的"累计能量比""误差 = 被丢弃奇异值的能量",都是指这个量。记住这一句,下文一切关于"能量"的说法就都有了落点。

既然前几项就装着绝大部分能量,那不如干脆只留前 \(k\) 项,扔掉尾巴:

\[ A_k=\sum_{i=1}^{k}\sigma_i\,u_i v_i^\top\qquad(k这就是秩-k 截断 rank-k truncation,得到的 \(A_k\) 是一个秩为 \(k\) 的矩阵,是对原矩阵的"压缩版"。

秩-k 低秩近似:奇异值加权的秩-1 外积之和A ≈ Σ σᵢ uᵢ vᵢᵀ(秩-1 外积之和)Aₖm×nσ₁u₁v₁ᵀ+σ₂u₂v₂ᵀ+σ₃u₃v₃ᵀ+ …只存前 k 项 ≈ k(m+n) 个数,远少于 m×n
秩-k 低秩近似 = 外积之和:A ≈ σ₁u₁v₁ᵀ + σ₂u₂v₂ᵀ + … + σₖuₖvₖᵀ。每一项是一个秩-1 矩阵(细列 u 乘细行 vᵀ 铺成一整块),按奇异值大小依次叠加,前几块就拼出 A 的主体。

它到底有多省?原本存 \(A\) 要 \(m\times n\) 个数;存 \(A_k\) 只需存前 \(k\) 个 \(u_i\)(共 \(mk\) 个数)、\(k\) 个 \(\sigma_i\)、\(k\) 个 \(v_i\)(共 \(nk\) 个数),合计约 \(k(m+n)\)。当 \(k\) 远小于 \(m,n\) 时,省下的存储是数量级的。

Eckart–Young 定理:截断是"最优"的

你可能担心:随手砍掉尾巴,会不会有更聪明的低秩矩阵比 \(A_k\) 更接近 \(A\)?Eckart–Young 定理给了一个让人安心的回答——没有。在所有秩不超过 \(k\) 的矩阵里,\(A_k\) 是最优逼近,而且误差有简洁的闭式:

\[ \min_{\operatorname{rank}(B)\le k}\|A-B\|_F=\|A-A_k\|_F=\sqrt{\sigma_{k+1}^2+\sigma_{k+2}^2+\dots+\sigma_r^2}. \]

这里 \(\|\cdot\|_F\) 是 Frobenius 范数(把矩阵所有元素平方求和再开根,相当于把矩阵拉直成长向量后的欧氏长度)。这条定理是低秩压缩的理论基石:它保证"按奇异值大小截断"不是某种近似启发式,而是数学上可证明的最优策略。误差就等于"被你扔掉的那些奇异值的能量"(开根号)——正是上面定义的那个能量。

奇异值衰减与累计能量比奇异值衰减累计能量奇异值σ₁σ₂σ₃σ₄σ₅σ₆σ₇σ₈累计能量比1.099% 能量取前 k 项即可k₀1 2 3 4 5 6 7 8k
左:奇异值 σ₁≥σ₂≥… 的衰减柱状图,自然数据下前几根明显高、迅速塌到接近 0。右:累计能量比 Σσᵢ²(前k) / Σσᵢ²(全部) 随 k 上升、很快逼近 1 的曲线,并标出 ‘k=k₀ 处达 99% 能量’的截断点。说明为何取前 k 项就够。

例题:秩-1 近似与误差

继续用上面的 \(A\)(\(\sigma_1=5,\sigma_2=3\))。它的最优秩-1 近似是 \(A_1=\sigma_1 u_1 v_1^\top\)。算出来

\[ A_1=\begin{bmatrix}2.5&2.5&0\\2.5&2.5&0\end{bmatrix}. \]

按 Eckart–Young,逼近误差应为 \(\|A-A_1\|_F=\sqrt{\sigma_2^2}=\sigma_2=3\)。直接验算 \(A-A_1=\begin{bmatrix}0.5&-0.5&2\\-0.5&0.5&-2\end{bmatrix}\),其 Frobenius 范数 \(=\sqrt{4\times 0.5^2+2\times 2^2}=\sqrt{1+8}=3\)(四个 \(\pm0.5\) 平方各 \(0.25\)、两个 \(\pm2\) 平方各 \(4\))。完全吻合。

import numpy as np
np.set_printoptions(precision=4, suppress=True)

A = np.array([[3., 2., 2.],
              [2., 3., -2.]])
U, s, Vt = np.linalg.svd(A, full_matrices=False)

# 秩-1 近似 = 第一项外积
A1 = s[0] * np.outer(U[:, 0], Vt[0])
print("A1 (秩-1 近似) =\n", A1)

err = np.linalg.norm(A - A1)              # Frobenius 范数
print("秩-1 逼近误差 =", round(err, 4))     # 3.0
print("Eckart-Young 预言 = sqrt(σ2^2) =", round(s[1], 4))  # 3.0 —— 完全一致

7. 条件数:矩阵"健不健康"的体检指标

奇异值里最大和最小的那两个,组成一个判断矩阵数值健康度的关键量——条件数 condition number

\[ \kappa(A)=\frac{\sigma_{\max}}{\sigma_{\min}}\ \ (\ge 1). \]

几何上它度量"椭圆有多扁":\(\kappa=1\) 是完美的圆(各方向增益相同,最健康);\(\kappa\) 很大说明椭圆被压得又细又长,某些方向几乎被压没了。这样的矩阵叫病态 ill-conditioned

为什么要在意?因为解方程 \(Ax=b\) 或做矩阵求逆时,条件数刻画了相对误差的最坏放大上界:严格地说,\(\dfrac{\|\Delta x\|}{\|x\|}\le \kappa\cdot\dfrac{\|\Delta b\|}{\|b\|}\)。也就是说,若 \(b\) 有千分之一的相对扰动,解 \(x\) 的相对误差最坏可达 \(\kappa\times\) 千分之一。注意这是"相对对相对"的最坏情形上界,不是"任意绝对扰动一律被放大 \(\kappa\) 倍"。\(\kappa\) 上万的矩阵,计算结果的有效数字会被吃掉好几位。

例题:一个病态矩阵

看 \(C=\begin{bmatrix}1&1\\1&1.0001\end{bmatrix}\)。两行几乎一模一样(线性相关得厉害),直觉上它"差一点就退化成秩 1 了"。它的奇异值约为 \(\sigma_1\approx2.0001\)、\(\sigma_2\approx5\times10^{-5}\),于是 \(\kappa\approx 4\times10^{4}\)。这意味着拿它解方程,最坏情况下,\(b\) 里的微小相对扰动会被放大约四万倍——一个典型的病态矩阵。

import numpy as np
B = np.array([[2., 0.], [0., 2.]])
C = np.array([[1., 1.], [1., 1.0001]])
print("健康矩阵 B 的条件数:", np.linalg.cond(B))   # 1.0,完美的圆
print("病态矩阵 C 的条件数:", round(np.linalg.cond(C), 1))  # ~40002
print("C 的奇异值:", np.linalg.svd(C, compute_uv=False))

8. 伪逆:当矩阵不能求逆时

普通的逆 \(A^{-1}\) 只对"方且满秩"的矩阵存在。可现实里我们经常想"反解"一个长方形或病态的方程组——比如最小二乘拟合。SVD 给出了一个万能替代品:伪逆(Moore–Penrose pseudo-inverse),记作 \(A^+\)。

做法朴素得可爱:既然 \(A=U\Sigma V^\top\),那就把三个因子各自"反着来",并把 \(\Sigma\) 里每个非零奇异值取倒数(零保持为零):

\[ A^+=V\,\Sigma^+\,U^\top,\qquad \Sigma^+ \text{ 把每个 }\sigma_i\ (\ne 0)\text{ 换成 }\tfrac{1}{\sigma_i}. \]

这里要补一句形状(第 3 节那种维度对账别丢):\(\Sigma\) 是 \(m\times n\),而 \(\Sigma^+\) 的形状是 \(n\times m\)——是把 \(\Sigma\) 先转置成 \(n\times m\),再对非零对角元取倒数。这样 \(V(n\times n)\cdot\Sigma^+(n\times m)\cdot U^\top(m\times m)\) 才正好拼成 \(n\times m\) 的 \(A^+\)(恰好是 \(A\) 形状的"转置",符合"反解"的直觉)。

当 \(A\) 可逆时,\(A^+\) 恰好就是 \(A^{-1}\);当 \(A\) 是长方形或不满秩时,\(A^+\) 给出最小二乘意义下的"最佳反解"。这也解释了第 7 节的病态问题:伪逆本身在数学上是良定义的,麻烦出在极小的奇异值取倒数后变成一个巨大的数——当 \(b\) 含噪声时,它会把 \(b\) 在最小奇异方向上的噪声分量放大 \(1/\sigma_{\min}\) 倍,于是解被噪声主导、极不稳定(这不是浮点溢出,而是"噪声放大")。所以实践中常把太小的 \(\sigma_i\) 直接当 0 截掉(这叫"截断伪逆",本质又回到了低秩思想)。

import numpy as np
np.set_printoptions(precision=4, suppress=True)
A = np.array([[3., 2., 2.],
              [2., 3., -2.]])     # 2x3,无法求普通逆
Aplus = np.linalg.pinv(A)        # 伪逆,形状 3x2
print("A+ 形状:", Aplus.shape)
print("A @ A+ =\n", A @ Aplus)   # 应得到 2x2 单位阵 I

9. 和 ML 的联系:低秩思想无处不在

ML 和 ML 的联系

把"大矩阵 ≈ 低秩矩阵"这一个想法,套到不同对象上,就长出了一整片 ML 应用:

  • 图像压缩。把一张灰度图当矩阵,做秩-k 截断只存前 k 个奇异向量。自然图像的奇异值衰减极快,往往 k 取几十就能肉眼无损,存储省一个数量级。这就是下面练习里"把小矩阵当图像"的玩法。
  • PCA via SVDPCA(主成分分析)是一种把高维数据投影到最重要的几个方向上的降维方法(我们会在后面的降维一课系统讲),它本质上要找数据中方差最大的那些方向。求这些方向有一条"教科书路径":构造数据的协方差矩阵 \(X^\top X\) 再做特征分解。但实践中几乎从不这么干——因为显式构造 \(X^\top X\) 会把条件数平方、放大数值误差。更稳的做法是直接对数据矩阵 \(X\) 做 SVD:右奇异向量 \(V\) 就是那些"方差最大的方向",奇异值的平方正比于各方向上的方差。这就是"SVD 是做 PCA 的更稳做法"。
  • 推荐系统。把"用户 × 物品"的评分表当矩阵,它绝大部分是空的。低秩假设是说"用户偏好其实由少数几个隐含因子(动作片爱好者、文艺片爱好者……)决定",于是用低秩矩阵补全缺失评分——这是经典协同过滤(如 Netflix 大奖方案)的数学内核。
  • 白化 whitening。许多算法喜欢"各方向方差相同"的数据。用 SVD 把数据沿各奇异方向除以对应奇异值(\(\Sigma\) 取倒数),就把椭圆形的数据云拉回成球形——本质就是让条件数变成 1。

ML 重点:LoRA——低秩微调的数学根基

这是 2021 年后微调大模型的主流方法,也是本节最该记住的联系。微调一个预训练模型,本质是给某个权重矩阵 \(W\)(动辄 \(4096\times4096\),一千多万参数)学一个更新量 \(\Delta W\),让新权重变成 \(W+\Delta W\)。直接学整个 \(\Delta W\) 太贵了。

LoRA(Low-Rank Adaptation)的核心假设:微调带来的更新 \(\Delta W\) "内在秩"很低——它本来就接近一个低秩矩阵。既然如此,干脆强制把它写成两个瘦矩阵的乘积:

\[ \Delta W = B A,\qquad B\in\mathbb{R}^{m\times r},\ A\in\mathbb{R}^{r\times n},\ r\ll \min(m,n). \]

这正是第 6 节"秩-k 截断"的思想反过来用:不是事后压缩一个已知矩阵,而是事前就把待学的矩阵约束在低秩子空间里。参数量从 \(m\times n\) 砍到 \(r(m+n)\)。拿 \(4096\times4096\)、取 \(r=8\) 算一笔账:原本 \(1677\) 万参数,LoRA 只需 \(2\times4096\times8=65536\) 个,不到原来的 0.4%。训练显存和存储都大幅下降,效果却几乎不掉——而它能成立的全部底气,就是这一课反复强调的那句话:大矩阵常常可以用低秩很好地近似

import numpy as np
# LoRA 省了多少参数?
d, r = 4096, 8
full = d * d                 # 完整 ΔW 的参数量
lora = 2 * d * r             # B(d×r) + A(r×d)
print("完整微调参数:", full)            # 16,777,216
print("LoRA 参数  :", lora)            # 65,536
print("占比:", round(lora / full * 100, 3), "%")   # 0.391 %

10. 综合代码:把小矩阵当"图像"压缩

下面这段把前面所有概念串起来:合成一个 \(8\times8\) 的"低秩+噪声"小图,看奇异值如何衰减、累计能量如何快速逼近 1、以及不同 \(k\) 的重建误差是否精确符合 Eckart–Young。

import numpy as np
np.set_printoptions(precision=3, suppress=True)
rng = np.random.default_rng(0)

# 合成一张 8x8 的"图像":一个秩-1 主结构 + 少量噪声
u1 = np.array([1, 1, 1, 1, 2, 2, 2, 2.])
v1 = np.array([1, 1, 1, 1, 1, 1, 2, 2.])
M = np.outer(u1, v1) + 0.3 * rng.standard_normal((8, 8))

U, s, Vt = np.linalg.svd(M, full_matrices=False)
print("奇异值:", s)                       # 第一个远大于其余 —— 主结构

# 累计能量:前 k 个奇异值占了多少"信息"(能量 = σ_i^2)
energy = np.cumsum(s**2) / np.sum(s**2)
print("累计能量比:", energy.round(4))      # 秩-1 就已 ~99%

# 各 k 的秩-k 重建误差 vs Eckart-Young 闭式预言
for k in [1, 2, 3]:
    Mk = (U[:, :k] * s[:k]) @ Vt[:k]      # = Σ_{i

要点:本课一图流

  • 分解:任意 \(A=U\Sigma V^\top\),几何上 = 旋转 \(V^\top\) → 轴向缩放 \(\Sigma\) → 旋转 \(U\)("旋转"泛指正交变换,可能含反射)。
  • 构件:\(\sigma_i\) 是各方向增益;\(U\)(\(m\times m\))、\(V\)(\(n\times n\))的列是左/右奇异向量;\(\sigma_i^2\) 是 \(A^\top A\) / \(AA^\top\) 的特征值。
  • 压缩:\(A=\sum\sigma_i u_i v_i^\top\),截断到前 \(k\) 项即最优低秩逼近(Eckart–Young),误差 \(=\sqrt{\sum_{i>k}\sigma_i^2}\);这里"能量"指 \(\sigma_i^2\),总能量 \(=\|A\|_F^2\)。
  • 健康:条件数 \(\kappa=\sigma_{\max}/\sigma_{\min}\)(相对误差的最坏放大上界);伪逆 \(A^+=V\Sigma^+U^\top\)(\(\Sigma^+\) 为 \(n\times m\))是万能"反解"。
  • ML:图像压缩 / PCA / 推荐 / 白化都是它,LoRA 是"事前低秩约束"版的同一思想。

调一调,观察现象

下面三个小任务都基于本课已经验证过的代码,改一个数、跑一下,看奇异值/能量/条件数怎么随之变化——把"低秩近似"和"病态"从文字变成你亲眼跑出来的数字。

任务 1:改截断秩 k,看重建误差

改什么:对本课的 \(A\)(奇异值 5 和 3)把 k 从 1 调到 2。
预期现象:\(k=1\) 时重建误差 \(=3.0000\),恰好等于被丢弃的 \(\sigma_2\);\(k=2\) 时误差降到 \(0.0000\)(两个非零奇异值全留下,精确还原)。
为什么:Eckart–Young 说截断误差 \(=\sqrt{\sum_{i>k}\sigma_i^2}\)。\(k=1\) 丢掉 \(\sigma_2=3\),误差就是 3;留满秩则无可丢,误差为 0。

import numpy as np
A = np.array([[3.,2.,2.],[2.,3.,-2.]])
U, s, Vt = np.linalg.svd(A, full_matrices=False)
for k in [1, 2]:
    Ak = (U[:, :k] * s[:k]) @ Vt[:k]
    err = np.linalg.norm(A - Ak)
    pred = np.sqrt((s[k:]**2).sum())
    print(f"k={k}: 实测误差={err:.4f}, 理论={pred:.4f}")

任务 2:让两行越来越像,看条件数飙升

改什么:把第 7 节病态矩阵里第二行的小扰动 eps 从 1.0 一路调到 0.001。
预期现象:条件数依次约为 6.85 → 42 → 4002,越调越大;同时最小奇异值从 0.382 一路缩到 0.0005,几乎贴到 0。
为什么eps 越小两行越接近线性相关,矩阵越逼近秩 1,于是 \(\sigma_{\min}\to 0\),而 \(\kappa=\sigma_{\max}/\sigma_{\min}\) 随之爆炸——这正是"病态"的来源。

import numpy as np
for eps in [1.0, 0.1, 0.001]:
    C = np.array([[1., 1.], [1., 1. + eps]])
    s = np.linalg.svd(C, compute_uv=False)
    print(f"eps={eps}: 条件数={np.linalg.cond(C):.2f}, 奇异值={s.round(4)}")

任务 3:加大噪声,看能量从"集中"变"分散"

改什么:对一个秩-1 主结构 + 噪声的 \(8\times8\) 矩阵,把噪声强度 noise 从 0.05 调到 1.5。
预期现象:秩-1 占的累计能量比从约 0.9997 一路掉到约 0.76——噪声越大,能量越分散到其余奇异值上。
为什么:"能量"就是 \(\sigma_i^2\)。低噪时主结构几乎独占第一个奇异值,秩-1 就够;噪声把能量摊到所有方向,第一个方向的占比自然下降——这也是"自然数据才低秩、纯噪声不低秩"的直观体现。

import numpy as np
rng = np.random.default_rng(0)
u1 = np.array([1,1,1,1,2,2,2,2.]); v1 = np.array([1,1,1,1,1,1,2,2.])
for noise in [0.05, 0.3, 1.5]:
    M = np.outer(u1, v1) + noise * rng.standard_normal((8, 8))
    s = np.linalg.svd(M, compute_uv=False)
    e1 = (s[0]**2) / (s**2).sum()
    print(f"noise={noise}: 秩-1 累计能量比={e1:.4f}")

动手练习

  1. 手算奇异值。对 \(A=\begin{bmatrix}2&0\\0&-3\end{bmatrix}\),先用 \(A^\top A\) 手算两个奇异值(提示:对角阵很好算,注意奇异值非负),再用代码验证。
    import numpy as np
    A = np.array([[2., 0.], [0., -3.]])
    s = np.linalg.svd(A, compute_uv=False)
    print("奇异值:", s)   # 和你手算的对一下?(应为 3, 2)
    print("特征值:", np.linalg.eigvals(A))  # 对比:特征值可以是负的
  2. 验证 Av=σu。对任意你喜欢的 \(3\times2\) 矩阵做 SVD,取出 \(v_1, u_1, \sigma_1\),验证 \(Av_1\) 是否等于 \(\sigma_1 u_1\)。
    import numpy as np
    A = np.array([[1., 2.], [3., 4.], [5., 6.]])
    U, s, Vt = np.linalg.svd(A, full_matrices=False)
    v1 = Vt[0]          # 第一个右奇异向量
    # TODO: 计算 A @ v1,并与 s[0]*U[:,0] 比较,打印两者的差的范数
  3. 能量曲线。合成一个 \(20\times20\) 的随机矩阵和一个"秩-3 + 噪声"矩阵,分别打印它们的累计能量比。对比:随机矩阵的能量是不是衰减得慢得多?(体会"自然数据才低秩"。)
  4. 截断伪逆。给第 7 节的病态矩阵 \(C\) 做 SVD,手动把最小的奇异值置 0 后再按 \(V\Sigma^+U^\top\) 拼伪逆,比较它与 np.linalg.pinv(C) 在解一个带噪声 \(b\) 时谁更稳。
    import numpy as np
    C = np.array([[1., 1.], [1., 1.0001]])
    U, s, Vt = np.linalg.svd(C)
    # TODO: 复制一份 s,把 s[-1] 设为很小或 0,重建截断伪逆并打印对比
  5. (挑战)小图压缩比。合成一个 \(32\times32\)、秩约为 5 的"图像",找出"累计能量达到 99%"所需的最小 \(k\),并算出相对原始存储 \(32\times32\) 的压缩比 \(k(32+32)/(32\times32)\)。

掌握自检

  • 给定一个 \(m\times n\) 矩阵,我能立刻说出 \(U,\Sigma,V\) 各自的形状,并解释 \(\Sigma\) 为什么是长方形的。
  • 我能用"圆 → 旋转 → 椭圆 → 旋转"向别人讲清 SVD 的几何含义,并指出椭圆半轴就是奇异值。
  • 我能说出 \(V\)、\(U\)、\(\sigma_i\) 分别和 \(A^\top A\)、\(AA^\top\) 的特征向量/特征值的对应关系,并解释为什么 SVD 对任意矩阵都存在。
  • 我能写出秩-k 截断公式,说出 Eckart–Young 定理的内容,并知道逼近误差等于被丢弃奇异值的能量 \(\sqrt{\sum_{i>k}\sigma_i^2}\)(能量 = \(\sigma_i^2\))。
  • 我能解释条件数 \(\kappa=\sigma_{\max}/\sigma_{\min}\) 的几何与数值含义(相对误差的最坏放大上界),看到 \(\kappa\) 很大就警觉"病态"。
  • 我能用一句话讲清 LoRA 为什么省参数,并指出它和"秩-k 截断"的关系(事前约束 vs 事后压缩)。

到这里,模块 1 的线性代数部分就全部讲完了——从向量、矩阵变换、内积投影、特征分解,一路走到今天的 SVD,你已经握齐了理解神经网络"前向计算"所需的全部代数工具。下一课我们换挡进入微积分:导数与梯度,去看模型是怎么学的——也就是它如何顺着"下坡方向"一步步调整自己的参数向量。