读完这一课,你将能够
- 对任意 \(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 干的事,就是把"圆 → 椭圆"这个过程拆成三个干净的步骤:
- \(V^\top\):旋转。先把空间转一下,找到一组特殊的、互相垂直的"输入方向"(即 \(V\) 的列),把它们对齐到坐标轴。旋转不改变任何长度和角度,圆还是圆。
- \(\Sigma\):沿坐标轴缩放。\(\Sigma\) 是对角矩阵,第 \(i\) 个对角元是 \(\sigma_i\)。它把第 \(i\) 根坐标轴方向拉伸 \(\sigma_i\) 倍。于是圆被压成了一个正椭圆,半轴长正好是 \(\sigma_1, \sigma_2, \dots\)。
- \(U\):再旋转。把这个正椭圆整体转到它在输出空间里真正的朝向。
"旋转"是个通俗说法。严格来说 \(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 有个漂亮的副产品:它把奇异向量天然分成了"有用的"和"被压扁的"两拨。
- 奇异值非零(\(\sigma>0\))的方向,是 \(A\) 的信息通道——输入沿这些方向喂进去,会被实实在在地搬运、放大到输出空间。
- 奇异值为零(\(\sigma=0\))的右奇异向量 \(v_i\),是被 \(A\) 直接压成 0 的输入方向,这些方向合起来就叫 \(A\) 的零空间 null space(即所有被 \(A\) 映射到原点的输入向量的集合)——一个"信息黑洞"。
这种"非零方向 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它到底有多省?原本存 \(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 近似与误差
继续用上面的 \(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 SVD。PCA(主成分分析)是一种把高维数据投影到最重要的几个方向上的降维方法(我们会在后面的降维一课系统讲),它本质上要找数据中方差最大的那些方向。求这些方向有一条"教科书路径":构造数据的协方差矩阵 \(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
跑出来你会看到:秩-1 就抓住了约 99% 的能量,每个 \(k\) 的实测重建误差和 Eckart–Young 闭式预言一位不差地相等。这就是低秩压缩"能省、且省得有保证"的全部秘密。
要点:本课一图流
- 分解:任意 \(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}")
动手练习
- 手算奇异值。对 \(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)) # 对比:特征值可以是负的 - 验证 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] 比较,打印两者的差的范数 - 能量曲线。合成一个 \(20\times20\) 的随机矩阵和一个"秩-3 + 噪声"矩阵,分别打印它们的累计能量比。对比:随机矩阵的能量是不是衰减得慢得多?(体会"自然数据才低秩"。)
- 截断伪逆。给第 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,重建截断伪逆并打印对比 - (挑战)小图压缩比。合成一个 \(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,你已经握齐了理解神经网络"前向计算"所需的全部代数工具。下一课我们换挡进入微积分:导数与梯度,去看模型是怎么学的——也就是它如何顺着"下坡方向"一步步调整自己的参数向量。
可以先放过的点
- 第 4 节里"奇异值为 0 的列怎么补齐"、四个基本子空间(零空间、左零空间……)的细节,现在只要记住"SVD 自动把方向分成通得过和通不过两堆"就够,等到后面专门的线代专题再回来抠。
- Eckart–Young 定理的证明不必现在啃下来——你先把它当成"按奇异值大小截断就是最优"这条可放心使用的结论,会用就行。
- 第 8 节伪逆里 \(\Sigma^+\) 是 \(n\times m\)、要先转置再取倒数这类维度对账,第一遍读糊了没关系,真正用到最小二乘求解时(以后的回归课)再回头逐维数清楚。
- 条件数那条不等式 \(\frac{\|\Delta x\|}{\|x\|}\le\kappa\frac{\|\Delta b\|}{\|b\|}\) 的严格推导可以暂缓,现在记住"\(\kappa\) 很大 = 病态 = 要警觉"这个直觉即可。