写在前面:万能的 SVD,缺席的算法
SVD 是线性代数的瑞士军刀。
你做主成分分析(PCA),底层是 SVD;你做推荐系统的协同过滤,底层是 SVD;你算伪逆、解最小二乘,底层是 SVD;你做图像压缩、信号去噪、潜在语义分析(LSA),底层还是 SVD。统计软件里凡是涉及"降维""求秩""解超定方程组"的功能,几乎绕不开它。
但翻开任何一本线性代数教材,SVD 这一章的写法几乎一模一样:先证明对任意矩阵 A 都存在分解 A=UΣVT,其中 U、 V 是正交阵、 Σ 是对角阵、对角元叫奇异值,然后画一个示意图,再举一个 2×2 的例子手算一遍,翻篇。
没人告诉你,怎么把一个 1000×1000 的矩阵数值地算成 UΣVT。
这是教科书和生产代码之间最大的一道鸿沟之一。因为真要算出来,你立刻撞上一堆教科书从来不提的问题:
- 能不能直接算 ATA 的特征值?为什么实际代码不这么干?
- A 不是对称矩阵,怎么办?
- 怎么保证算出来的 U、 V 真的是正交阵?
- 迭代收敛到什么程度才算够?什么时候该认输?
- 奇异值算出来是无序的,怎么保证降序输出?
今天我们用 这份约 300 行的真实 C 源码 svd.c,把这些问题一个一个填平。它是 LINPACK 的 hsvdc 的 C 移植,忠实保留了 1970 年代末期那批数值分析大师(其中就有 MATLAB 之父 Cleve Moler)写下的每一行工程决策。
第一个陷阱:为什么不能直接算 ATA 的特征值
先说数学。奇异值的定义是这样的: ATA 是一个对称半正定矩阵,它的特征值全是非负实数,把这些特征值开方,就是奇异值 σi=λi(ATA) ,对应的特征向量就是 V。然后 U=AVΣ−1。
看起来这条路最直接------算特征值而已,对称矩阵的特征值算法一抓一大把,为什么要单独发明 SVD?
因为条件数会平方。
回忆条件数的定义: κ(A)=σmax/σmin,衡量矩阵在数值上"病态"的程度。一个 κ(A)=108 的矩阵,在 double 精度(约 15~16 位有效数字)下,做线性求解就会损失 8 位精度,几乎不能用了。
现在你想通过 ATA 来算奇异值。注意 ATA 的特征值是 σi2,于是 κ(ATA)=σmax2/σmin2=κ(A)2。原本 108 的条件数,平方之后变成 1016------直接超出了 double 的精度极限 。换句话说,原本还能勉强算的矩阵,一旦你先算 ATA 再做特征分解,最小那几个奇异值就完全淹没在浮点噪声里了。
举个直观的数:若 σmin=10−4、 σmax=104,那么 κ(A)=108,仍然在 double 的可处理范围;但 σmin2=10−8、 σmax2=108, κ(ATA)=1016,那些小特征值会被舍入误差完全吞掉。
所以生产代码从不直接形成 ATA。 正确的做法是对 A 本身做正交变换,把奇异值直接"挤"出来,不让它先被平方。这就是 SVD 数值算法的全部出发点。
svd.c 开头的文件注释就写得很明白:
c
/*============================================================================*
* svd.c ------ 奇异值分解 X = U·S·Vᵀ 的 C 移植
* 对应 Fortran:hsvdc.for
*
* 数学:
* 第一阶段「双对角化」:交替用左 Householder(清列)与右 Householder
* (清行次对角),把任意 n×p 矩阵化成双对角阵 B(仅主、次两条对角)。
* 第二阶段「Givens QR 收敛」:对双对角阵做带 Wilkinson 隐式位移的
* QR 迭代(成对 Givens 旋转),把次对角 e 逐步清零,主对角 s 收敛为
* 奇异值;同步把变换累积进 U(左奇异向量)与 V(右奇异向量)。
*============================================================================*/
注意"双对角化"和"Givens QR 收敛"两个词,这就是 SVD 的两阶段。但真正读懂源码,你会发现还有隐藏的第三阶段------排序。所以本文标题叫"三步走"。
三阶段算法总览
在读代码之前,先把整体框架画清楚。SVD 数值算法分成三步:
第一阶段:Householder 双对角化。 用一系列 Householder 反射(左右交替),把任意 n×p 矩阵 A 化成一个只有主对角线和次对角线有非零元素的"双对角阵" B:
B= s1e1s2e2⋱⋱sm
这一步是直接变换(不迭代),约 O(np2) 的计算量。变换的同时,把所有 Householder 反射累积进 U 和 V(这两个矩阵在最开始是单位阵)。
第二阶段:Givens QR 迭代收敛。 双对角阵 B 仍然不是对角阵------它的次对角线 ei 还需要清零。这一步用带 Wilkinson 隐式位移的 QR 迭代,通过成对的 Givens 旋转,把次对角 ei 逐步压到 0,主对角 si 收敛为奇异值。注意:双对角阵的奇异值就是原矩阵的奇异值 ,这是 SVD 算法最关键的观察之一。同步把旋转累积进 U 和 V。
第三阶段:排序。 第二阶段输出的奇异值是无序的(哪个先收敛哪个先冒出来),但 SVD 的标准约定是奇异值降序排列。代码用一个朴素的冒泡排序把每个新算出的奇异值"浮"到它该在的位置,同时同步交换 U、 V 对应的列。
整个过程的核心思想是:绝不形成 ATA,而是通过正交相似变换,把 A 的奇异值问题转化为一个双对角阵的奇异值问题,再迭代地解掉它。 中间每一步都保持正交性,所以条件数始终是 κ(A) 而不是 κ(A)2。
下面逐段拆代码。
第一阶段逐段拆解:Householder 双对角化
几个关键的存储约定
hsvdc 函数的开头是这样的:
c
int hsvdc(double *x, int n, int p, double *s, double *e,
double *u, int ldu, double *v, int ldv, double *work, int job) {
#define X(i,j) x[((i)-1)*p + (j)-1] /* 行主序,前导维 p */
#define U(i,j) u[((i)-1)*ldu + (j)-1]
#define V(i,j) v[((i)-1)*ldv + (j)-1]
#define S(k) s[(k)-1]
#define E(k) e[(k)-1]
#define W(k) work[(k)-1]
这里有一个和 bmath 其他模块不同的约定------行主序 。注释里专门强调:"与 bmath 其他模块不同,hsvdc 内部沿用 LINPACK DSVDC 的行主序约定"。这是为了和原版 Fortran 代码逐行对照。下标从 1 开始(Fortran 传统),所以有 (i)-1 这种 1-based 到 0-based 的转换。
四个数组各司其职:s 存主对角线(最终收敛为奇异值),e 存次对角线(迭代过程中清零),u、v 累积左、右奇异向量。job 参数编码了用户想要什么:job%10 决定要不要算 V,(job%100)/10 决定 U 是哪种形式(不算、 n×n、 n×m、 n×p)。
主循环:左右 Householder 交替进行
c
nct = (n - 1 < p) ? n - 1 : p; /* min(n-1, p) */
nrt = ((p - 2) < n) ? (p - 2) : n; /* min(p-2, n) */
if (nrt < 0) nrt = 0;
lu = (nct > nrt) ? nct : nrt;
...
ll = -p;
for (l = 1; l <= lu; l++) {
ll = ll + pp1; /* 对角 X(l,l) 的位置 */
lp1 = l + 1;
if (l > nct) goto L20;
/* 左反射:把第 l 列的 l+1..n 行清零 → 产生奇异值 s(l) */
S(l) = dnrm2(n - l + 1, &X(l,l), p);
if (S(l) == R0) goto L10;
if (X(l,l) != R0) S(l) = copysign(S(l), X(l,l));
t1 = R1 / S(l);
dscal(n - l + 1, t1, &X(l,l), p);
X(l,l) = R1 + X(l,l);
L10:
S(l) = -S(l);
这一段做的是左 Householder 反射 ------把第 l 列从 l+1 行往下全部清零,把这一列的范数集中到对角元 X(l,l) 上,这个范数就是未来的奇异值 s(l)。
Householder 反射的数学是这样的:给定向量 x,找一个反射 H=I−2vvT/∥v∥2(其中 v 是单位向量),使得 Hx 等于 ±∥x∥e1。代码里的实现非常巧妙,没有显式构造 H,而是把反射向量直接就地存在 X 矩阵的下三角部分(被清零的位置正好用来存反射向量)。
我们逐句看这段的工程细节:
dnrm2(n - l + 1, &X(l,l), p)算第 l 列从对角元往下的 2-范数。注意第三个参数p是步长(stride)------因为是行主序,沿一列走每次地址要跳 p 个元素。这一段对应教科书的 ∥x∥。if (X(l,l) != R0) S(l) = copysign(S(l), X(l,l))这一行非常关键------选择反射的符号,避免灾难性的相消 。如果直接取 −∥x∥ 而 X(l,l) 本身是正数,那么 X(l,l)−(−∥x∥)=X(l,l)+∥x∥ 没问题;但如果取错符号, X(l,l)−∥x∥ 可能是两个几乎相等的数相减,丢掉几乎所有有效数字。copysign让 ∥x∥ 带上和 X(l,l) 相同的符号,是 Householder 反射的标准数值技巧。dscal(n - l + 1, t1, &X(l,l), p)把这一列除以 s(l),归一化为单位反射向量。X(l,l) = R1 + X(l,l)把对角元加 1------这是 Householder 反射向量的标准形式(反射向量 v=x+sign(x1)∥x∥e1,除以 ∥x∥ 之后对角元变成 1+x1/∥x∥)。S(l) = -S(l)最后把符号翻回来,存入 s(l)。
注意 goto L10、goto L20 这种控制流------这是从 Fortran 直接翻译过来的"扁平"控制流,对现代 C 程序员不友好,但好处是可以和原版 hsvdc.for 逐行对照。LINPACK 那个年代,Fortran 没有 if-else 的块结构嵌套,全靠行号跳转。
左反射应用到右侧、右反射清次对角
左反射算完后,要把它应用到矩阵剩下的列上(把第 l 行的右侧元素也变换掉),同时把第 l 行的元素存到次对角数组 e:
c
L20:
/* 左反射应用到右侧各列,并把行 l 存进 e */
if (p < lp1) goto L50;
for (j = lp1; j <= p; j++) {
if (l > nct) goto L30;
if (S(l) == R0) goto L30;
t = -ddot(n - l + 1, &X(l,l), p, &X(l,j), p) / X(l,l);
daxpy(n - l + 1, t, &X(l,l), p, &X(l,j), p);
L30:
E(j) = X(l,j);
}
这里 ddot 算的是反射向量 v 和第 j 列的内积,daxpy 把 −(vTxj/vl)⋅v 加到第 j 列上------这就是 Householder 反射作用在一列上的标准公式 x←x−(vTx/vTv)v 的实数化版本(因为反射向量已经被归一化,且 vl=1+X(l,l)/∥x∥,所以这里直接用 X(l,l) 当分母)。
接下来是右反射------把第 l 行的次对角(从 l+1 列开始)清零,产生次对角元 e(l):
c
L70:
if (l > nrt) goto L150;
/* 右反射:把第 l 行的次对角清零 → 产生 e(l) */
E(l) = dnrm2(p - l, &E(lp1), 1);
if (E(l) == R0) goto L80;
if (E(lp1) != R0) E(l) = copysign(E(l), E(lp1));
t1 = R1 / E(l);
dscal(p - l, t1, &E(lp1), 1);
E(lp1) = R1 + E(lp1);
注意步长变成了 1------因为这里沿着第 l 行走,行主序下行内的元素是连续的。这正好和左反射的步长 p 对偶。
至此一整轮双对角化完成:左反射清掉一列、产生一个 s(l);右反射清掉一行的次对角、产生一个 e(l)。重复 lu = max(nct, nrt) 次之后,原矩阵 X 被化为双对角阵(虽然 X 本身的存储被反射向量覆盖了,但 s 和 e 这两个一维数组完整地表示了双对角结构)。
边界处理:补齐 s 和 e 的尾部
双对角化的最后一小段处理边界:
c
L170:
/* ------ 边界处理:设置 s/e 的尾端 ------ */
m = (p < n + 1) ? p : n + 1;
nctp1 = nct + 1;
nrtp1 = nrt + 1;
if (nct < p) S(nctp1) = X(nctp1, nctp1);
if (n < m) S(m) = R0;
if (nrtp1 < m) E(nrtp1) = X(nrtp1, m);
E(m) = R0;
因为前面的主循环只走到 lu,对于 n=p 的非方阵情况,最后还有一些边界元素需要单独抄到 s 和 e 数组里。这种"主循环 + 边界补丁"的模式是数值代码的标准做法------循环内部为了效率走最常用的形态,边界情况单独处理。
累积 U 和 V
双对角化之后,前面存的反射向量还要"回放"出来累积成 U 和 V 矩阵:
c
L200:
if (nct < 1) goto L290;
for (lb = 1; lb <= nct; lb++) {
l = nct - lb + 1;
if (S(l) == R0) goto L250;
lp1 = l + 1;
if (ncu < lp1) goto L220;
for (j = lp1; j <= ncu; j++) {
t = -ddot(n-l+1, &U(l,l), ldu, &U(l,j), ldu) / U(l,l);
daxpy(n-l+1, t, &U(l,l), ldu, &U(l,j), ldu);
}
L220:
dscal(n-l+1, -R1, &U(l,l), ldu);
U(l,l) = R1 + U(l,l);
lm1 = l - 1;
for (i = 1; i <= lm1; i++) U(i,l) = R0;
goto L270;
L250:
for (i = 1; i <= n; i++) U(i,l) = R0;
U(l,l) = R1;
L270:
;
}
注意 for (lb = 1; lb <= nct; lb++) { l = nct - lb + 1; ... }------倒序 累积。为什么?因为 Householder 反射要从最后一个开始"回放"才能得到正确的 U。每个反射 Hl 作用在当前 U 上: U←UHl。数学上反射的乘积必须按顺序右乘,倒序循环正是这个意思。
第 V 部分的累积逻辑完全对称(处理次对角对应的右反射),代码风格一致,这里不重复展开。
第二阶段逐段拆解:Givens QR 迭代收敛
进入第二阶段。从这里开始,我们要处理的是双对角阵 {s,e},目标是把 e 全部清零,让 s 收敛为奇异值。
外层循环:找"断点"判断是否收敛
c
L350:
/* ============ 第二阶段:Givens QR 迭代收敛 ============ */
mm = m;
iter = 0;
L360:
if (m == 0) goto L620;
if (iter < 500) goto L370;
info = m;
goto L620;
L370:
/* ------ 找最大的 l,使 e(l) 可忽略(LINPACK 收敛判断:ztest==test)------ */
for (lb = 1; lb <= m; lb++) {
l = m - lb;
if (l == 0) goto L400;
test = fabs(S(l)) + fabs(S(l+1));
ztest = test + fabs(E(l));
if (ztest == test) { E(l) = R0; goto L400; }
}
L400:
这一段是 SVD 算法里最值得反复琢磨的地方之一。
首先看外层循环结构:L360 是主循环入口,每次先检查"是否所有奇异值都收敛了"(m == 0)和"是否迭代次数超过 500"(info = m; goto L620)。这就是为什么函数返回值 info > 0 表示"第 info 个奇异值没收敛"------m 是当前还没收敛的奇异值个数,500 次没收敛就直接返回,告诉调用者"这部分我不保证"。
接着是 for (lb = 1; lb <= m; lb++) { l = m - lb; ... }------从下往上扫描,找第一个"可以认为 e(l)=0"的位置 l。
然后是收敛判据:
c
test = fabs(S(l)) + fabs(S(l+1));
ztest = test + fabs(E(l));
if (ztest == test) { E(l) = R0; goto L400; }
这就是著名的 ztest == test 判据,整个 LINPACK SVD 里最有争议、也最精妙的一行。
字面意思是:把 ∣E(l)∣ 加到 test 上得到 ztest,如果 ztest == test(浮点相等),就认为 ∣E(l)∣ 小到可以忽略,强制设成 0。
为什么能用浮点相等判断?这是 IEEE 754 浮点数的一个性质:两个浮点数相加,如果其中一个比另一个小很多(小到落在对方的最低有效位之外),加完之后结果就等于较大的那个数 。比如 double 有 52 位尾数,约 15~16 位十进制精度,如果 ∣E(l)∣<test×2−53,那么 test + |E(l)| 在浮点意义下就等于 test。
这个判据的精妙之处在于:它自动适应数据的尺度 。test 是两个相邻奇异值的绝对值之和, ∣E(l)∣ 跟它比。如果奇异值整体很大(比如 1010 量级),那么 ∣E(l)∣ 只要小于 10−6 左右就算收敛;如果奇异值整体很小( 10−10 量级),那么 ∣E(l)∣ 小于 10−25 才算收敛。不需要任何手动调阈值,跟着数据的尺度走。
但这也是个陷阱 :这个判据依赖编译器不做激进的优化(比如不做 -ffast-math)。如果你编译时开了快速数学优化,浮点加法可能被换成更高精度的内部表示,ztest == test 就永远不成立,算法永远不收敛。LINPACK 原版的这个技巧在现代编译器下要特别小心 ------后来的 LAPACK(dbdsqr)就改成了显式的相对容差判断 |E(l)| <= eps * (|S(l)| + |S(l+1)|),目的就是避开这个依赖。
读到这一行,你就理解了为什么 SVD 这种"教科书数学"在工程上那么微妙------收敛判据本身就是一个数值决策,写错一点点,要么永远不收敛,要么提前停止损失精度。
四种 KCASE 分支:处理不同的对角块情况
找到断点 l 之后,下一个问题是:从 l 到 m 这一段双对角阵,应该怎么处理?LINPACK 把情况分成四种 kase:
c
L400:
if (l == m - 1) { kase = 4; goto L480; } /* s(m) 已分离 */
lp1 = l + 1;
/* ------ 找可忽略的 s(ls),确定对角块的处理方式 ------ */
ls = l;
for (lls = lp1; lls <= m + 1; lls++) {
ls = m - lls + lp1;
if (ls == l) break;
test = R0;
if (ls != m) test += fabs(E(ls));
if (ls != l + 1) test += fabs(E(ls - 1));
ztest = test + fabs(S(ls));
if (ztest == test) { S(ls) = R0; break; }
}
if (ls != l) {
if (ls != m) { kase = 2; l = ls; }
else kase = 1;
} else {
kase = 3;
}
L480:
l = l + 1;
if (kase == 1) goto L490;
else if (kase == 2) goto L520;
else if (kase == 3) goto L540;
goto L570;
这段在干一件事:找这段双对角阵里有没有"可忽略的奇异值" S(ls) 。如果 S(ls) 小到相对可忽略(同样用 ztest == test 判断),就强制设为 0------这意味着矩阵在这一行"分裂"成两个独立的对角块,可以分别处理。
四种情况对应不同的对角块结构:
- KASE 1 :底部那个奇异值 S(m) 已经收敛,但还连着上一行( E(m−1)=0)。需要从顶部把这条"尾巴" E(m−1) 清掉。
- KASE 2 :中间某个 S(ls)=0,导致矩阵分裂。需要从底部把连接上方那条 E(l−1) 清掉。
- KASE 3 :完整的对角块(没有可忽略的 S),进入主迭代------Wilkinson 隐式位移 QR 扫描。
- KASE 4 :底部奇异值 S(m) 已经完全分离( l=m−1),直接做符号规正和排序。
我们依次看。
KASE 1 和 KASE 2:清"尾巴"
c
L490: /* KASE 1:从顶部把 e(l-1) 的「尾巴」清掉 */
mm1 = m - 1;
f = E(m - 1);
E(m - 1) = R0;
for (kk = l; kk <= mm1; kk++) {
k = mm1 - kk + l;
t1 = S(k);
drotg(&t1, &f, &cs, &sn);
S(k) = t1;
if (k != l) { f = -sn * E(k-1); E(k-1) = cs * E(k-1); }
if (wantv) drot(p, &V(1,k), ldv, &V(1,m), ldv, cs, sn);
}
goto L610;
KASE 1 的核心是用 drotg(生成 Givens 旋转)从上往下扫,把那条还没清掉的次对角 E(m−1) 通过一系列 Givens 旋转"推"出去。每次 drotg 生成一对 (cs,sn)(cosine 和 sine),对应一个 2×2 的旋转矩阵:
G=(cs−snsncs)
drot(p, &V(1,k), ldv, &V(1,m), ldv, cs, sn) 把这个旋转同步应用到 V 的第 k 列和第 m 列------保证 V 始终是正交阵。
KASE 2 完全对称,从底部往上扫,把 E(l−1) 清掉:
c
L520: /* KASE 2:从底部把 e(l-1) 清掉 */
f = E(l - 1);
E(l - 1) = R0;
for (k = l; k <= m; k++) {
t1 = S(k);
drotg(&t1, &f, &cs, &sn);
S(k) = t1;
f = -sn * E(k);
E(k) = cs * E(k);
if (wantu) drot(n, &U(1,k), ldu, &U(1,l-1), ldu, cs, sn);
}
goto L610;
注意 KASE 1 同步 V,KASE 2 同步 U------这是左右奇异向量的区分。
KASE 3:核心的 Wilkinson 隐式位移 QR 迭代
这是整个 SVD 算法的核心。前面三种情况都是"清尾巴"的辅助操作,真正的迭代收敛在 KASE 3。
c
L540: /* KASE 3:Wilkinson 隐式位移 QR 扫描(核心迭代)*/
scale = fabs(S(m)); b = fabs(S(m-1));
if (b > scale) scale = b; b = fabs(E(m-1));
if (b > scale) scale = b; b = fabs(S(l));
if (b > scale) scale = b; b = fabs(E(l));
if (b > scale) scale = b;
sm = S(m) / scale; smm1 = S(m-1) / scale; emm1 = E(m-1) / scale;
sl = S(l) / scale; el = E(l) / scale;
先看开头这几行------显式的 scale 缩放 。scale 取这段双对角阵里所有元素的绝对值最大者,然后把所有元素除以 scale。为什么要这么做?防止溢出 。Wilkinson 位移公式里有 sm2、 sm−12 这种平方项,如果原始数据是 10150 量级,平方就溢出了。先除以 scale 让所有数变成 O(1) 量级,算完位移之后再隐式带回去。这是和 Cholesky 行列式那个"手工科学计数法"异曲同工的工程技巧。
接下来是 Wilkinson 隐式位移的计算:
c
b = ((smm1 + sm) * (smm1 - sm) + emm1 * emm1) / 2.0;
c = (sm * emm1) * (sm * emm1);
shift = R0;
if (b != R0 || c != R0) {
shift = sqrt(b * b + c);
if (b < R0) shift = -shift;
shift = c / (b + shift);
}
f = (sl + sm) * (sl - sm) - shift;
g = sl * el;
这段在算 Wilkinson 位移。简单说,位移是当前对角块底部 2×2 子矩阵的特征值估计 ,加上这个位移之后 QR 迭代会以立方速度收敛(这是 QR 算法最神奇的数学性质之一)。注意 (smm1 + sm) * (smm1 - sm) 而不是直接写 smm1*smm1 - sm*sm------这是数值分析里经典的"避免相消"写法,等价数学但保留精度。
"隐式位移"的意思是:我们不真的把矩阵减去位移、再做 QR 分解、再乘回去(那样要显式构造 B−μI),而是通过一个特殊的"bulge chasing"过程,从左上角开始用一系列 Givens 旋转"追"出一个伪位移的效果。这种做法的好处是不破坏双对角结构、不引入数值误差。
接下来就是真正的 bulge chasing------成对的 Givens 旋转:
c
mm1 = m - 1;
for (k = l; k <= mm1; k++) {
drotg(&f, &g, &cs, &sn);
if (k != l) E(k - 1) = f;
f = cs * S(k) + sn * E(k);
E(k) = cs * E(k) - sn * S(k);
g = sn * S(k + 1);
S(k + 1) = cs * S(k + 1);
if (wantv) drot(p, &V(1,k), ldv, &V(1,k+1), ldv, cs, sn);
drotg(&f, &g, &cs, &sn);
S(k) = f;
f = cs * E(k) + sn * S(k + 1);
S(k + 1) = -sn * E(k) + cs * S(k + 1);
g = sn * E(k + 1);
E(k + 1) = cs * E(k + 1);
if (wantu && k < n) drot(n, &U(1,k), ldu, &U(1,k+1), ldu, cs, sn);
}
E(m - 1) = f;
iter = iter + 1;
goto L610;
注意每个 k 循环里有两次 drotg:第一次是右乘(应用到 V),第二次是左乘(应用到 U)。这一对旋转把"凸起"(bulge)从当前位置推到下一个位置,最终推到右下角消失。这就是隐式 QR 算法的核心机制。
为什么用 Givens 旋转而不是 Householder 反射来收敛双对角阵? 这是一个值得专门讲的问题。Householder 反射是把一个向量整体反射到某个坐标轴方向,最擅长处理稠密向量;而双对角阵每行每列只有两个非零元素,用 Givens 这种"两个数之间的 2×2 旋转"刚好够用,计算量小得多(每次 O(1) 而不是 O(n))。对稠密矩阵用 Householder,对双对角阵用 Givens------这是数值线性代数 50 年的标准经验。
每完成一轮 KASE 3,iter 加 1,回到主循环 L360 重新判断收敛。理论上 Wilkinson 位移让迭代立方收敛,绝大多数奇异值在 5~10 次迭代内就会满足 ztest == test。
第三阶段拆解:符号规正和冒泡排序
KASE 4 是处理已经收敛、即将"出栈"的那个奇异值 S(m):
c
L570: /* KASE 4:s(l) 符号规正 + 冒泡排序保证降序 */
if (S(l) < R0) {
S(l) = -S(l);
if (wantv) dscal(p, -R1, &V(1,l), ldv);
}
while (l != mm) { /* L590 冒泡,把 s(l) 浮到正确位置 */
if (S(l) >= S(l + 1)) break;
tt = S(l); S(l) = S(l + 1); S(l + 1) = tt;
if (wantv && l < p) dswap(p, &V(1,l), ldv, &V(1,l+1), ldv);
if (wantu && l < n) dswap(n, &U(1,l), ldu, &U(1,l+1), ldu);
l = l + 1;
}
iter = 0;
m = m - 1;
这段做两件事:
第一件:符号规正。 奇异值按定义是非负的,但前面 Givens 迭代算出来的 S(l) 可能带任意符号(因为 Givens 旋转的 (cs,sn) 组合不唯一)。如果 S(l)<0,就取绝对值,同时把 V 的对应列整体取反------保持 A=UΣVT 仍然成立 (因为 V 的列乘 -1 同时 Σ 的对应元取绝对值,乘积不变)。注意 U 这里不动,因为符号问题已经在 V 上吸收掉了。
第二件:冒泡排序保证降序。 这是个值得说的地方。SVD 的标准约定是 σ1≥σ2≥⋯≥σm≥0,但 QR 迭代不保证收敛顺序------可能 σ3 比 σ1 先收敛。LINPACK 的做法是:每收敛一个奇异值,就用冒泡排序把它从当前位置往右"浮"到正确位置。
while (l != mm) 是个内嵌的小冒泡:如果当前 S(l) 比右边的 S(l+1) 小,就交换两者,同时交换 U 和 V 的对应列,然后 l 加 1 继续比较。直到找到 S(l)≥S(l+1) 的位置停下。
为什么用 O(n) 的冒泡排序而不是整体快速排序?因为这里每次只插入一个新奇异值 ------前面已经排好的部分仍然有序,新值最多需要交换 O(n) 次就能浮到正确位置。整体下来 n 个奇异值的总排序成本是 O(n2),对通常的 n 完全可接受,而且冒泡排序是稳定的、可以同步交换 U、 V 列 ------一次 dswap 同时处理一对列。这种"边收敛边排序"的设计是 LINPACK 的标志。
最后 m = m - 1------把已处理的奇异值"踢出"工作区,剩下的 m−1 个奇异值继续主循环。
返回值
整个函数的结尾非常简洁:
c
L620:
#undef X
#undef U
#undef V
#undef S
#undef E
#undef W
return info;
}
info = 0 表示所有奇异值都收敛了;info > 0 表示底部第 info 个奇异值在 500 次迭代内没收敛------这是调用者需要警惕的信号。#undef 是为了清理宏定义(因为 X、U 这些是函数内部的局部宏,避免污染其他包含 bmath.h 的代码)。
工程洞察:教科书不讲的那些事
把整段 svd.c 读下来,我们提炼出 6 个生产代码必须处理、但教科书基本不讲的工程细节。
洞察一:两阶段拆分是性能和稳定性的折中
为什么不直接对原矩阵做 QR 迭代?因为稠密矩阵的 QR 迭代每次成本 O(n3),要做 n 次就是 O(n4),根本不可行。双对角化把矩阵压缩到只有 O(n) 个非零元的状态,之后每次 QR 迭代成本只有 O(n) ,整体降到 O(n3)。这是 SVD 算法之所以能在实际数据上跑起来的根本原因。
更妙的是,双对角化用 Householder(适合稠密),QR 收敛用 Givens(适合稀疏双对角)------每种工具都用在了最合适的场景。
洞察二:四种 KCASE 分支处理对角块的不同情况
教科书讲 QR 算法通常只讲"主迭代",但实际矩阵会分裂------某些奇异值为 0 或者某些次对角先收敛,导致矩阵分解成几个独立的小块。KASE 1~4 就是分别处理"完整对角块""分裂点""尾部奇异值"这几种情况。如果只写 KASE 3 而不写其他三个分支,算法会在某些矩阵上彻底卡死。
洞察三:收敛判据 ztest == test 是把双刃剑
这个判据优雅、自适应、零参数,但依赖 IEEE 754 浮点的精确语义 。在 -ffast-math、FMA 融合、扩展精度寄存器(x87 的 80 位)等情况下可能失效。现代 LAPACK 改用显式的 eps * test 相对容差,就是为了避免这个陷阱。读到这一行,你能真切感受到 1978 年的 LINPACK 和现代数值库之间的代际差异。
洞察四:scale 缩放防溢出
Wilkinson 位移公式里有平方项,原始数据 10150 量级就直接溢出。代码先取这段对角块的 scale、把所有元素除以 scale 再算位移 ,让中间计算始终在 O(1) 量级。这种"显式缩放"在 LINPACK 的所有特征值/奇异值算法里反复出现,是数值库必修的防御性编程。
洞察五:冒泡排序保证降序输出
QR 迭代不保证收敛顺序。LINPACK 的策略是边收敛边用冒泡排序插入 ,每次 O(n) 同步交换 U、 V 的对应列。整体 O(n2) 的排序成本在大规模 SVD 里完全可接受,而且代码极简。
洞察六:为什么不直接形成 ATA
回到开头那个问题。条件数平方 会丢失一半精度,最小奇异值完全被淹没。svd.c 通过 Householder 双对角化 + Givens 迭代,全程只对 A 做正交相似变换,条件数始终保持 κ(A)------这是 SVD 算法相比"先算 ATA 再做对称特征分解"的根本优势。
把整条链串起来:SVD 支撑了什么
读懂 svd.c 这 300 行,你就读懂了现代数据分析的半个底层:
css
SVD 分解 (hsvdc)
├── PCA:协方差矩阵的特征分解等价于对中心化数据做 SVD,U 的列就是主成分
├── 伪逆:A⁺ = V Σ⁺ Uᵀ,秩亏矩阵求逆的唯一正确方式
├── 最小二乘:超定方程 Ax ≈ b 的解 = V Σ⁺ Uᵀ b,比正规方程更稳定
├── 秩判定:奇异值的"断崖"就是矩阵的有效秩
├── 协同过滤:推荐系统把用户-物品矩阵分解成低秩近似(Netflix 大奖的核心方法)
├── 图像压缩:保留前 k 个奇异值就是最好的低秩近似(Eckart-Young 定理)
└── 潜在语义分析:文本-词矩阵做 SVD 提取主题
这些应用的本质,全是"找一个矩阵的低秩近似" 。而 SVD 给出的低秩近似,在 Frobenius 范数下是理论最优的------这就是著名的 Eckart-Young 定理:矩阵 A 的最佳秩 k 近似,就是保留前 k 个奇异值对应的 UkΣkVkT。
这也是为什么 Netflix 100 万美元大奖的核心算法就是 SVD 的变种,为什么推荐系统的祖师爷算法叫"SVD 协同过滤",为什么 PCA 的数学本质就是 SVD。
学完这一篇,你再看 Python 的 numpy.linalg.svd、R 的 svd()、MATLAB 的 svd()、SPSS 的因子分析对话框,会明白它们点下去的那一瞬间,跑的就是这份代码的两阶段、四个 KCASE、ztest == test 收敛判据、冒泡排序的全部精华。
这不是抽象的数学课,而是具体的代码课。
总结:教科书不讲的 7 个点
- 不能直接算 ATA :条件数会平方,最小奇异值淹没在浮点噪声里。必须对 A 直接做正交变换。
- 两阶段拆分 :Householder 双对角化( O(np2),适合稠密)+ Givens QR 收敛( O(n2) 单次迭代,适合双对角)。每种工具用在最合适的场景。
- 四种 KCASE 分支:处理完整对角块(KASE 3 主迭代)、分裂点(KASE 2)、尾部"尾巴"(KASE 1)、已分离奇异值(KASE 4 排序)的不同情况,缺一不可。
ztest == test收敛判据:利用 IEEE 754 浮点加法的舍入特性,自动适应数据尺度,但依赖编译器不做激进优化------LAPACK 后来改成显式相对容差就是为了避开这个陷阱。- Wilkinson 隐式位移:让 QR 迭代立方收敛,"隐式"通过 bulge chasing 实现,不破坏双对角结构、不引入减位移的数值误差。
- scale 缩放防溢出 :算位移前先把所有元素除以本段最大值,让中间计算保持在 O(1) 量级。
- 冒泡排序保证降序 :边收敛边插入,每次 O(n) 同步交换 U、 V 列,整体 O(n2),简单且稳定。
如果只记一句话,那就是:SVD 不是"算 ATA 的特征值",而是一场精心编排的两阶段正交变换------先压成双对角,再用 Wilkinson 位移的 Givens QR 立方收敛,最后冒泡成降序 。教科书只告诉你 A=UΣVT 的存在,代码告诉你怎么把它算出来。
这是 工业级算法源码系列最复杂的一篇。下一篇,我们会回到相对友好的领域,看一个看似简单、实则藏着无数工程细节的算法------特征值分解的对称 QR 迭代。SVD 学通了,对称特征值问题就是它的孪生兄弟。