SVD 的三步走:双对角化、Givens 收敛、排序

写在前面:万能的 SVD,缺席的算法

SVD 是线性代数的瑞士军刀。

你做主成分分析(PCA),底层是 SVD;你做推荐系统的协同过滤,底层是 SVD;你算伪逆、解最小二乘,底层是 SVD;你做图像压缩、信号去噪、潜在语义分析(LSA),底层还是 SVD。统计软件里凡是涉及"降维""求秩""解超定方程组"的功能,几乎绕不开它。

但翻开任何一本线性代数教材,SVD 这一章的写法几乎一模一样:先证明对任意矩阵 AA A 都存在分解 A=UΣVTA = U\Sigma V^T A=UΣVT,其中 UU U、 VV V 是正交阵、 Σ\Sigma Σ 是对角阵、对角元叫奇异值,然后画一个示意图,再举一个 2×2 的例子手算一遍,翻篇。

没人告诉你,怎么把一个 1000×1000 的矩阵数值地算成 UΣVTU\Sigma V^T UΣVT。

这是教科书和生产代码之间最大的一道鸿沟之一。因为真要算出来,你立刻撞上一堆教科书从来不提的问题:

  • 能不能直接算 ATAA^TA ATA 的特征值?为什么实际代码不这么干?
  • AA A 不是对称矩阵,怎么办?
  • 怎么保证算出来的 UU U、 VV V 真的是正交阵?
  • 迭代收敛到什么程度才算够?什么时候该认输?
  • 奇异值算出来是无序的,怎么保证降序输出?

今天我们用 这份约 300 行的真实 C 源码 svd.c,把这些问题一个一个填平。它是 LINPACK 的 hsvdc 的 C 移植,忠实保留了 1970 年代末期那批数值分析大师(其中就有 MATLAB 之父 Cleve Moler)写下的每一行工程决策。


第一个陷阱:为什么不能直接算 ATAA^TA ATA 的特征值

先说数学。奇异值的定义是这样的: ATAA^TA ATA 是一个对称半正定矩阵,它的特征值全是非负实数,把这些特征值开方,就是奇异值 σi= λi(ATA) \sigma_i = \sqrt{\lambda_i(A^TA)} σi=λi(ATA) ,对应的特征向量就是 VV V。然后 U=AVΣ−1U = AV\Sigma^{-1} U=AVΣ−1。

看起来这条路最直接------算特征值而已,对称矩阵的特征值算法一抓一大把,为什么要单独发明 SVD?

因为条件数会平方。

回忆条件数的定义: κ(A)=σmax⁡/σmin⁡\kappa(A) = \sigma_{\max}/\sigma_{\min} κ(A)=σmax/σmin,衡量矩阵在数值上"病态"的程度。一个 κ(A)=108\kappa(A) = 10^8 κ(A)=108 的矩阵,在 double 精度(约 15~16 位有效数字)下,做线性求解就会损失 8 位精度,几乎不能用了。

现在你想通过 ATAA^TA ATA 来算奇异值。注意 ATAA^TA ATA 的特征值是 σi2 \sigma_i^2 σi2,于是 κ(ATA)=σmax⁡2/σmin⁡2=κ(A)2\kappa(A^TA) = \sigma_{\max}^2/\sigma_{\min}^2 = \kappa(A)^2 κ(ATA)=σmax2/σmin2=κ(A)2。原本 10810^8 108 的条件数,平方之后变成 101610^{16} 1016------直接超出了 double 的精度极限 。换句话说,原本还能勉强算的矩阵,一旦你先算 ATAA^TA ATA 再做特征分解,最小那几个奇异值就完全淹没在浮点噪声里了。

举个直观的数:若 σmin⁡=10−4 \sigma_{\min} = 10^{-4} σmin=10−4、 σmax⁡=104 \sigma_{\max} = 10^4 σmax=104,那么 κ(A)=108\kappa(A) = 10^8 κ(A)=108,仍然在 double 的可处理范围;但 σmin⁡2=10−8 \sigma_{\min}^2 = 10^{-8} σmin2=10−8、 σmax⁡2=108 \sigma_{\max}^2 = 10^8 σmax2=108, κ(ATA)=1016\kappa(A^TA) = 10^{16} κ(ATA)=1016,那些小特征值会被舍入误差完全吞掉。

所以生产代码从不直接形成 ATAA^TA ATA。 正确的做法是对 AA 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×pn\times p n×p 矩阵 AA A 化成一个只有主对角线和次对角线有非零元素的"双对角阵" BB B:
B= ( s1 e1 s2 e2 ⋱ ⋱ sm ) B = \begin{pmatrix} s_1 & e_1 & & \\ & s_2 & e_2 & \\ & & \ddots & \ddots \\ & & & s_{m} \end{pmatrix} B= s1e1s2e2⋱⋱sm

这一步是直接变换(不迭代),约 O(np2)O(np^2) O(np2) 的计算量。变换的同时,把所有 Householder 反射累积进 UU U 和 VV V(这两个矩阵在最开始是单位阵)。

第二阶段:Givens QR 迭代收敛。 双对角阵 BB B 仍然不是对角阵------它的次对角线 ei e_i ei 还需要清零。这一步用带 Wilkinson 隐式位移的 QR 迭代,通过成对的 Givens 旋转,把次对角 ei e_i ei 逐步压到 0,主对角 si s_i si 收敛为奇异值。注意:双对角阵的奇异值就是原矩阵的奇异值 ,这是 SVD 算法最关键的观察之一。同步把旋转累积进 UU U 和 VV V。

第三阶段:排序。 第二阶段输出的奇异值是无序的(哪个先收敛哪个先冒出来),但 SVD 的标准约定是奇异值降序排列。代码用一个朴素的冒泡排序把每个新算出的奇异值"浮"到它该在的位置,同时同步交换 UU U、 VV V 对应的列。

整个过程的核心思想是:绝不形成 ATAA^TA ATA,而是通过正交相似变换,把 AA A 的奇异值问题转化为一个双对角阵的奇异值问题,再迭代地解掉它。 中间每一步都保持正交性,所以条件数始终是 κ(A)\kappa(A) κ(A) 而不是 κ(A)2\kappa(A)^2 κ(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 存次对角线(迭代过程中清零),uv 累积左、右奇异向量。job 参数编码了用户想要什么:job%10 决定要不要算 VV V,(job%100)/10 决定 UU U 是哪种形式(不算、 n×nn\times n n×n、 n×mn\times m n×m、 n×pn\times p 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 反射 ------把第 ll l 列从 l+1l+1 l+1 行往下全部清零,把这一列的范数集中到对角元 X(l,l)X(l,l) X(l,l) 上,这个范数就是未来的奇异值 s(l)s(l) s(l)。

Householder 反射的数学是这样的:给定向量 xx x,找一个反射 H=I−2vvT/∥v∥2H = I - 2vv^T/\|v\|^2 H=I−2vvT/∥v∥2(其中 vv v 是单位向量),使得 HxHx Hx 等于 ±∥x∥e1 \pm\|x\|e_1 ±∥x∥e1。代码里的实现非常巧妙,没有显式构造 HH H,而是把反射向量直接就地存在 XX X 矩阵的下三角部分(被清零的位置正好用来存反射向量)。

我们逐句看这段的工程细节:

  • dnrm2(n - l + 1, &X(l,l), p) 算第 ll l 列从对角元往下的 2-范数。注意第三个参数 p 是步长(stride)------因为是行主序,沿一列走每次地址要跳 pp p 个元素。这一段对应教科书的 ∥x∥\|x\| ∥x∥。
  • if (X(l,l) != R0) S(l) = copysign(S(l), X(l,l)) 这一行非常关键------选择反射的符号,避免灾难性的相消 。如果直接取 −∥x∥- \|x\| −∥x∥ 而 X(l,l)X(l,l) X(l,l) 本身是正数,那么 X(l,l)−(−∥x∥)=X(l,l)+∥x∥X(l,l) - (-\|x\|) = X(l,l) + \|x\| X(l,l)−(−∥x∥)=X(l,l)+∥x∥ 没问题;但如果取错符号, X(l,l)−∥x∥X(l,l) - \|x\| X(l,l)−∥x∥ 可能是两个几乎相等的数相减,丢掉几乎所有有效数字。copysign ∥x∥\|x\| ∥x∥ 带上和 X(l,l)X(l,l) X(l,l) 相同的符号,是 Householder 反射的标准数值技巧。
  • dscal(n - l + 1, t1, &X(l,l), p) 把这一列除以 s(l)s(l) s(l),归一化为单位反射向量
  • X(l,l) = R1 + X(l,l) 把对角元加 1------这是 Householder 反射向量的标准形式(反射向量 v=x+sign(x1)∥x∥e1v = x + \text{sign}(x_1)\|x\|e_1 v=x+sign(x1)∥x∥e1,除以 ∥x∥\|x\| ∥x∥ 之后对角元变成 1+x1/∥x∥ 1 + x_1/\|x\| 1+x1/∥x∥)。
  • S(l) = -S(l) 最后把符号翻回来,存入 s(l)s(l) s(l)。

注意 goto L10goto L20 这种控制流------这是从 Fortran 直接翻译过来的"扁平"控制流,对现代 C 程序员不友好,但好处是可以和原版 hsvdc.for 逐行对照。LINPACK 那个年代,Fortran 没有 if-else 的块结构嵌套,全靠行号跳转。

左反射应用到右侧、右反射清次对角

左反射算完后,要把它应用到矩阵剩下的列上(把第 ll l 行的右侧元素也变换掉),同时把第 ll 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 算的是反射向量 vv v 和第 jj j 列的内积,daxpy −(vTxj/vl)⋅v -(v^T x_j / v_l) \cdot v −(vTxj/vl)⋅v 加到第 jj j 列上------这就是 Householder 反射作用在一列上的标准公式 x←x−(vTx/vTv)vx \leftarrow x - (v^Tx/v^Tv) v x←x−(vTx/vTv)v 的实数化版本(因为反射向量已经被归一化,且 vl=1+X(l,l)/∥x∥ v_l = 1 + X(l,l)/\|x\| vl=1+X(l,l)/∥x∥,所以这里直接用 X(l,l)X(l,l) X(l,l) 当分母)。

接下来是右反射------把第 ll l 行的次对角(从 l+1l+1 l+1 列开始)清零,产生次对角元 e(l)e(l) 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------因为这里沿着第 ll l 行走,行主序下行内的元素是连续的。这正好和左反射的步长 p 对偶。

至此一整轮双对角化完成:左反射清掉一列、产生一个 s(l)s(l) s(l);右反射清掉一行的次对角、产生一个 e(l)e(l) e(l)。重复 lu = max(nct, nrt) 次之后,原矩阵 XX X 被化为双对角阵(虽然 XX X 本身的存储被反射向量覆盖了,但 ss s 和 ee e 这两个一维数组完整地表示了双对角结构)。

边界处理:补齐 ss s 和 ee 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≠pn \ne p n=p 的非方阵情况,最后还有一些边界元素需要单独抄到 ss s 和 ee e 数组里。这种"主循环 + 边界补丁"的模式是数值代码的标准做法------循环内部为了效率走最常用的形态,边界情况单独处理。

累积 UU U 和 VV V

双对角化之后,前面存的反射向量还要"回放"出来累积成 UU U 和 VV 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 反射要从最后一个开始"回放"才能得到正确的 UU U。每个反射 Hl H_l Hl 作用在当前 UU U 上: U←UHl U \leftarrow U H_l U←UHl。数学上反射的乘积必须按顺序右乘,倒序循环正是这个意思。

VV V 部分的累积逻辑完全对称(处理次对角对应的右反射),代码风格一致,这里不重复展开。


第二阶段逐段拆解:Givens QR 迭代收敛

进入第二阶段。从这里开始,我们要处理的是双对角阵 {s,e}\{s, e\} {s,e},目标是把 ee e 全部清零,让 ss 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)=0e(l) = 0 e(l)=0"的位置 ll 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)∣|E(l)| ∣E(l)∣ 加到 test 上得到 ztest,如果 ztest == test(浮点相等),就认为 ∣E(l)∣|E(l)| ∣E(l)∣ 小到可以忽略,强制设成 0。

为什么能用浮点相等判断?这是 IEEE 754 浮点数的一个性质:两个浮点数相加,如果其中一个比另一个小很多(小到落在对方的最低有效位之外),加完之后结果就等于较大的那个数 。比如 double 有 52 位尾数,约 15~16 位十进制精度,如果 ∣E(l)∣<test×2−53|E(l)| < \text{test} \times 2^{-53} ∣E(l)∣<test×2−53,那么 test + |E(l)| 在浮点意义下就等于 test

这个判据的精妙之处在于:它自动适应数据的尺度test 是两个相邻奇异值的绝对值之和, ∣E(l)∣|E(l)| ∣E(l)∣ 跟它比。如果奇异值整体很大(比如 101010^{10} 1010 量级),那么 ∣E(l)∣|E(l)| ∣E(l)∣ 只要小于 10−610^{-6} 10−6 左右就算收敛;如果奇异值整体很小( 10−1010^{-10} 10−10 量级),那么 ∣E(l)∣|E(l)| ∣E(l)∣ 小于 10−2510^{-25} 10−25 才算收敛。不需要任何手动调阈值,跟着数据的尺度走

但这也是个陷阱 :这个判据依赖编译器不做激进的优化(比如不做 -ffast-math)。如果你编译时开了快速数学优化,浮点加法可能被换成更高精度的内部表示,ztest == test 就永远不成立,算法永远不收敛。LINPACK 原版的这个技巧在现代编译器下要特别小心 ------后来的 LAPACK(dbdsqr)就改成了显式的相对容差判断 |E(l)| <= eps * (|S(l)| + |S(l+1)|),目的就是避开这个依赖。

读到这一行,你就理解了为什么 SVD 这种"教科书数学"在工程上那么微妙------收敛判据本身就是一个数值决策,写错一点点,要么永远不收敛,要么提前停止损失精度。

四种 KCASE 分支:处理不同的对角块情况

找到断点 ll l 之后,下一个问题是:从 ll l 到 mm 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) S(ls) 。如果 S(ls)S(ls) S(ls) 小到相对可忽略(同样用 ztest == test 判断),就强制设为 0------这意味着矩阵在这一行"分裂"成两个独立的对角块,可以分别处理。

四种情况对应不同的对角块结构:

  • KASE 1 :底部那个奇异值 S(m)S(m) S(m) 已经收敛,但还连着上一行( E(m−1)≠0E(m-1) \ne 0 E(m−1)=0)。需要从顶部把这条"尾巴" E(m−1)E(m-1) E(m−1) 清掉。
  • KASE 2 :中间某个 S(ls)=0S(ls) = 0 S(ls)=0,导致矩阵分裂。需要从底部把连接上方那条 E(l−1)E(l-1) E(l−1) 清掉。
  • KASE 3 :完整的对角块(没有可忽略的 SS S),进入主迭代------Wilkinson 隐式位移 QR 扫描。
  • KASE 4 :底部奇异值 S(m)S(m) S(m) 已经完全分离( l=m−1l = m-1 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)E(m-1) E(m−1) 通过一系列 Givens 旋转"推"出去。每次 drotg 生成一对 (cs,sn)(cs, sn) (cs,sn)(cosine 和 sine),对应一个 2×2 的旋转矩阵:

G= ( cs sn −sn cs ) G = \begin{pmatrix} cs & sn \\ -sn & cs \end{pmatrix} G=(cs−snsncs)

drot(p, &V(1,k), ldv, &V(1,m), ldv, cs, sn) 把这个旋转同步应用到 VV V 的第 kk k 列和第 mm m 列------保证 VV V 始终是正交阵

KASE 2 完全对称,从底部往上扫,把 E(l−1)E(l-1) 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 同步 VV V,KASE 2 同步 UU 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 s_m^2 sm2、 sm−12 s_{m-1}^2 sm−12 这种平方项,如果原始数据是 1015010^{150} 10150 量级,平方就溢出了。先除以 scale 让所有数变成 O(1)O(1) 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−μIB - \mu I 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;

注意每个 kk k 循环里有两次 drotg:第一次是右乘(应用到 VV V),第二次是左乘(应用到 UU U)。这一对旋转把"凸起"(bulge)从当前位置推到下一个位置,最终推到右下角消失。这就是隐式 QR 算法的核心机制。

为什么用 Givens 旋转而不是 Householder 反射来收敛双对角阵? 这是一个值得专门讲的问题。Householder 反射是把一个向量整体反射到某个坐标轴方向,最擅长处理稠密向量;而双对角阵每行每列只有两个非零元素,用 Givens 这种"两个数之间的 2×2 旋转"刚好够用,计算量小得多(每次 O(1)O(1) O(1) 而不是 O(n)O(n) O(n))。对稠密矩阵用 Householder,对双对角阵用 Givens------这是数值线性代数 50 年的标准经验。

每完成一轮 KASE 3,iter 加 1,回到主循环 L360 重新判断收敛。理论上 Wilkinson 位移让迭代立方收敛,绝大多数奇异值在 5~10 次迭代内就会满足 ztest == test


第三阶段拆解:符号规正和冒泡排序

KASE 4 是处理已经收敛、即将"出栈"的那个奇异值 S(m)S(m) 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)S(l) S(l) 可能带任意符号(因为 Givens 旋转的 (cs,sn)(cs, sn) (cs,sn) 组合不唯一)。如果 S(l)<0S(l) < 0 S(l)<0,就取绝对值,同时把 VV V 的对应列整体取反------保持 A=UΣVTA = U\Sigma V^T A=UΣVT 仍然成立 (因为 VV V 的列乘 -1 同时 Σ\Sigma Σ 的对应元取绝对值,乘积不变)。注意 UU U 这里不动,因为符号问题已经在 VV V 上吸收掉了。

第二件:冒泡排序保证降序。 这是个值得说的地方。SVD 的标准约定是 σ1≥σ2≥⋯≥σm≥0 \sigma_1 \ge \sigma_2 \ge \dots \ge \sigma_m \ge 0 σ1≥σ2≥⋯≥σm≥0,但 QR 迭代不保证收敛顺序------可能 σ3 \sigma_3 σ3 比 σ1 \sigma_1 σ1 先收敛。LINPACK 的做法是:每收敛一个奇异值,就用冒泡排序把它从当前位置往右"浮"到正确位置

while (l != mm) 是个内嵌的小冒泡:如果当前 S(l)S(l) S(l) 比右边的 S(l+1)S(l+1) S(l+1) 小,就交换两者,同时交换 UU U 和 VV V 的对应列,然后 ll l 加 1 继续比较。直到找到 S(l)≥S(l+1)S(l) \ge S(l+1) S(l)≥S(l+1) 的位置停下。

为什么用 O(n)O(n) O(n) 的冒泡排序而不是整体快速排序?因为这里每次只插入一个新奇异值 ------前面已经排好的部分仍然有序,新值最多需要交换 O(n)O(n) O(n) 次就能浮到正确位置。整体下来 nn n 个奇异值的总排序成本是 O(n2)O(n^2) O(n2),对通常的 nn n 完全可接受,而且冒泡排序是稳定的、可以同步交换 UU U、 VV V 列 ------一次 dswap 同时处理一对列。这种"边收敛边排序"的设计是 LINPACK 的标志。

最后 m = m - 1------把已处理的奇异值"踢出"工作区,剩下的 m−1m-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 是为了清理宏定义(因为 XU 这些是函数内部的局部宏,避免污染其他包含 bmath.h 的代码)。


工程洞察:教科书不讲的那些事

把整段 svd.c 读下来,我们提炼出 6 个生产代码必须处理、但教科书基本不讲的工程细节。

洞察一:两阶段拆分是性能和稳定性的折中

为什么不直接对原矩阵做 QR 迭代?因为稠密矩阵的 QR 迭代每次成本 O(n3)O(n^3) O(n3),要做 nn n 次就是 O(n4)O(n^4) O(n4),根本不可行。双对角化把矩阵压缩到只有 O(n)O(n) O(n) 个非零元的状态,之后每次 QR 迭代成本只有 O(n)O(n) O(n) ,整体降到 O(n3)O(n^3) 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 位移公式里有平方项,原始数据 1015010^{150} 10150 量级就直接溢出。代码先取这段对角块的 scale、把所有元素除以 scale 再算位移 ,让中间计算始终在 O(1)O(1) O(1) 量级。这种"显式缩放"在 LINPACK 的所有特征值/奇异值算法里反复出现,是数值库必修的防御性编程。

洞察五:冒泡排序保证降序输出

QR 迭代不保证收敛顺序。LINPACK 的策略是边收敛边用冒泡排序插入 ,每次 O(n)O(n) O(n) 同步交换 UU U、 VV V 的对应列。整体 O(n2)O(n^2) O(n2) 的排序成本在大规模 SVD 里完全可接受,而且代码极简。

洞察六:为什么不直接形成 ATAA^TA ATA

回到开头那个问题。条件数平方 会丢失一半精度,最小奇异值完全被淹没。svd.c 通过 Householder 双对角化 + Givens 迭代,全程只对 AA A 做正交相似变换,条件数始终保持 κ(A)\kappa(A) κ(A)------这是 SVD 算法相比"先算 ATAA^TA 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 定理:矩阵 AA A 的最佳秩 kk k 近似,就是保留前 kk k 个奇异值对应的 UkΣkVkT U_k \Sigma_k V_k^T UkΣkVkT。

这也是为什么 Netflix 100 万美元大奖的核心算法就是 SVD 的变种,为什么推荐系统的祖师爷算法叫"SVD 协同过滤",为什么 PCA 的数学本质就是 SVD。

学完这一篇,你再看 Python 的 numpy.linalg.svd、R 的 svd()、MATLAB 的 svd()、SPSS 的因子分析对话框,会明白它们点下去的那一瞬间,跑的就是这份代码的两阶段、四个 KCASE、ztest == test 收敛判据、冒泡排序的全部精华。

这不是抽象的数学课,而是具体的代码课。


总结:教科书不讲的 7 个点

  1. 不能直接算 ATAA^TA ATA :条件数会平方,最小奇异值淹没在浮点噪声里。必须对 AA A 直接做正交变换。
  2. 两阶段拆分 :Householder 双对角化( O(np2)O(np^2) O(np2),适合稠密)+ Givens QR 收敛( O(n2)O(n^2) O(n2) 单次迭代,适合双对角)。每种工具用在最合适的场景。
  3. 四种 KCASE 分支:处理完整对角块(KASE 3 主迭代)、分裂点(KASE 2)、尾部"尾巴"(KASE 1)、已分离奇异值(KASE 4 排序)的不同情况,缺一不可。
  4. ztest == test 收敛判据:利用 IEEE 754 浮点加法的舍入特性,自动适应数据尺度,但依赖编译器不做激进优化------LAPACK 后来改成显式相对容差就是为了避开这个陷阱。
  5. Wilkinson 隐式位移:让 QR 迭代立方收敛,"隐式"通过 bulge chasing 实现,不破坏双对角结构、不引入减位移的数值误差。
  6. scale 缩放防溢出 :算位移前先把所有元素除以本段最大值,让中间计算保持在 O(1)O(1) O(1) 量级。
  7. 冒泡排序保证降序 :边收敛边插入,每次 O(n)O(n) O(n) 同步交换 UU U、 VV V 列,整体 O(n2)O(n^2) O(n2),简单且稳定。

如果只记一句话,那就是:SVD 不是"算 ATAA^TA ATA 的特征值",而是一场精心编排的两阶段正交变换------先压成双对角,再用 Wilkinson 位移的 Givens QR 立方收敛,最后冒泡成降序 。教科书只告诉你 A=UΣVTA = U\Sigma V^T A=UΣVT 的存在,代码告诉你怎么把它算出来。

这是 工业级算法源码系列最复杂的一篇。下一篇,我们会回到相对友好的领域,看一个看似简单、实则藏着无数工程细节的算法------特征值分解的对称 QR 迭代。SVD 学通了,对称特征值问题就是它的孪生兄弟。

相关推荐
躬行见万象1 小时前
《VLA 系列》UniLab 强化训练 | G1 机器人 |复现
算法
统计实现局1 小时前
对称不定分解(Bunch-Kaufman):为什么 Cholesky 不够用
算法
统计实现局1 小时前
dqrsl 拆解:拿着 QR 结果能算出哪 5 种东西
算法
统计实现局1 小时前
为什么 Cholesky 求逆比 Gauss-Jordan 快一倍——行列式溢出防护详
算法
To_OC13 小时前
LC 994 腐烂的橘子:人人都说是 BFS 入门题,我却写了三遍才过
javascript·算法·leetcode
金銀銅鐵16 小时前
[Python] 扩展欧几里得算法
python·数学·算法
To_OC19 小时前
LC 200 岛屿数量:经典 DFS 入门题,我第一次写居然连方向都搞错了
javascript·算法·leetcode
To_OC1 天前
LC 128 最长连续序列:别上来就排序,O (n) 解法才是这题的灵魂
javascript·算法·leetcode