ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Gram-Schmidt正交化数值稳定性深度解析:从CGS到MGS与Householder

2026/9/29 6:58:18 拓冰建站 浏览量
Gram-Schmidt正交化数值稳定性深度解析:从CGS到MGS与Householder 写这篇Gram-Schmidt正交化笔记起因是上周帮一位做点云配准的朋友排查程序异常。他从激光扫描数据里提取了一组近似线性相关的测量向量想恢复出坐标系的三个标准正交基——这是Gram-Schmidt正交化最典型的应用场景。结果他直接套了网上最常见的经典算法代码跑出来的基向量一会儿正交一会儿不正交每一帧数据的结果都抖得厉害。这个案例几乎是教科书级的标准翻车现场理论课上完美的Gram-Schmidt正交化公式一旦落到浮点数世界里远没有想象中那么可靠。借着这次排查过程我把自己从原理推导、代码实现到数值稳定性踩坑的完整笔记整理出来希望能帮到正在做数值计算、图形学或机器学习相关工作的朋友。这篇文章会讲清楚四件事Gram-Schmidt正交化的数学直觉和手写实现、经典算法在计算机里为什么容易失效、修正版算法MGS如何止血以及工程师在实际项目里应该怎么选型。适合需要自己实现正交化逻辑的开发者也适合刚学完线性代数、想知道这东西到底有什么用的学生。1. 先搞清楚正交化到底在解决什么问题1.1 没有正交基时坐标计算有多痛苦先回到最朴素的问题。在三维空间里我们早就习惯了x、y、z三个坐标轴两两垂直向量(1,2,3)的含义一目了然——沿x轴走1步、沿y轴走2步、沿z轴走3步。这种坐标系之所以好用核心就在于三个基向量互相正交且长度为1想提取某个方向的分量时直接做点积就能得到坐标。但如果手里的基向量不是正交的呢假设你拿到了三条空间向量它们张成了整个三维空间可是两两之间夹角只有30度。这时候想把一个已知向量分解成这三个方向的坐标最直接的方法是把问题写成线性方程组来解。三个方程三个未知数还好说一旦维度上升到几十上百每次坐标换算都要解方程组计算成本和对误差的敏感度都会直线上升。这还只是坐标系的便利性问题。更麻烦的场景出现在最小二乘拟合里——工程领域到处都要用。最小二乘需要解超定方程组Axb教科书里最经典的解法是构造正规方程(AᵀA)xAᵀb。但这里有个隐蔽的坑AᵀA会把矩阵的条件数平方原来条件数100的矩阵经过这一步直接变成10000数值稳定性急剧恶化。而如果能把A分解成QR的形式即Q是正交矩阵、R是上三角矩阵那么最小二乘问题化为RxQᵀb由于正交矩阵不放大误差整个求解过程的稳定性会好很多。而Gram-Schmidt正交化正是获得QR分解的第一种、也是最直观的算法。你把矩阵A的每一列看成一组向量正交化的过程就是在构造Q的各列同时记录下来的投影系数就组成了R矩阵。1.2 正交、正交基、标准正交基概念必须分清楚开始写代码前三个概念得先掰扯清楚很多翻车事故都是从这里埋下的。正交向量两个非零向量的点积为0内积空间中两向量夹角90度。正交基一组线性无关的向量两两之间互相正交。正交基自动满足线性无关这点在线性代数里是重要定理。标准正交基在正交基的基础上每个向量的模长L2范数都是1。也就是说一组标准正交基里的向量既两两正交又都是单位向量。Gram-Schmidt正交化做的事情就是把手头一组线性无关但形状很乱的向量一步步变形成一组标准正交基。需要注意的是整个过程保持张成的子空间不变——变换前后的向量组张成同一个线性空间只是内部坐标系被掰正了。我在实际项目里见过不少只做归一化不做正交化的代码有人把一组非正交向量各自除以自己的长度就宣称得到了正交基。归一化只解决了长度问题向量之间的夹角完全没动这是两码事。1.3 Gram-Schmidt在工程里的三个典型位置第一个位置就是上面说过的QR分解。任何一个列满秩矩阵都能做QR分解Gram-Schmidt是推导这个分解最自然的路径。第二个位置是计算特征值时的子空间迭代。比如Arnoldi方法做Krylov子空间迭代时每一步都需要把新生成的向量与前面积累的基向量正交化否则基底会在几十步之后彻底塌缩。第三个位置是计算机图形学里的坐标系构建。模型矩阵的旋转分量理论上应该是一个正交矩阵但经过无数次矩阵乘法累乘后浮点误差会让三个基向量慢慢变形不再是标准正交基。这时候就需要对提取出来的向量组重新正交化把漂移纠回来。这三个位置里第三个是最容易踩坑的——图形学里为了性能经常用float32计算误差积累比double快得多后面会详细说。2. 经典Gram-Schmidt(CGS)原理推导与第一版Python实现2.1 核心思想每次从向量里剥掉上一批方向的分量经典Gram-Schmidt的直觉其实特别简单一句话就能概括每处理一个新向量先把它在前面已经得到的所有正交方向上的投影全部减掉剩下的残余就是与前面都正交的新方向。这个过程可以类比成在墙上钉钉子定位。第一颗钉子随便钉它就是基准方向。第二颗钉子钉下去后你要保证它和第一颗不重合于是把第二颗钉子在第一颗方向上的影子砍掉只保留垂直方向的分量。第三颗钉子要同时避开前两颗的方向把影子分别投影到前两颗方向上再砍掉。一个一个处理下去每一颗新钉子都和之前所有钉子严格垂直。之所以要先有线性无关这个前提是因为如果输入向量本身就线性相关那么从某个位置起减去所有投影后残余向量会是零向量没有新方向可以构造了。所以算法运行前检查一下向量的线性相关性是个好习惯。2.2 公式拆解投影、减法、归一化三步走设输入向量组为v₁, v₂, ..., vₙ目标是输出标准正交基u₁, u₂, ..., uₙ。算法分三步第一步定基准。第一个向量直接作为正交基的起点做归一化u₁ v₁ / ‖v₁‖第二步剥投影。对第k个向量(k≥2)先把vₖ在前面所有已构造好的u₁, u₂, ..., uₖ₋₁方向上的分量全部减掉wₖ vₖ - Σᵢ₌₁ᵏ⁻¹ proj_{uᵢ}(vₖ)其中proj_{uᵢ}(vₖ) (uᵢ·vₖ)uᵢ。因为uᵢ已经是单位向量分母就是1投影公式大大简化了——这也是为什么我们通常先把第一个向量归一化再往下走。如果不归一化投影公式就要写成(vₖ·uᵢ)/(uᵢ·uᵢ)·uᵢ稍显啰嗦。第三步归一化。uₖ wₖ / ‖wₖ‖注意检查一下wₖ的长度。如果非常接近0说明vₖ与前面所有向量几乎线性相关需要处理或丢弃。整个过程的正确性可以用一个非常小的例子验证在二维平面里v₁(3,1)v₂(1,2)。第一步得u₁(0.9487,0.3162)然后v₂在这个方向上的投影是(1.2649,0.4216)。减去投影得到w₂(-0.2649,1.5784)归一化后u₂(-0.1655,0.9862)。验证一下u₁·u₂≈0正交性成立。2.3 手写CGS并用小例子验证正交性下面用Python实现经典Gram-Schmidt。我用的是numpy主要是为了向量运算方便核心逻辑完全手写不调用任何现成的正交化函数。import numpy as np def cgs(Q): 经典Gram-Schmidt正交化 输入: Q - (m, n)矩阵列向量线性无关 输出: Q - 正交化后的列向量R - 上三角投影系数矩阵 m, n Q.shape Q Q.astype(float).copy() R np.zeros((n, n)) for j in range(n): v Q[:, j].copy() # 减去前 j 列方向上的投影 for i in range(j): R[i, j] Q[:, i].dot(v) v v - R[i, j] * Q[:, i] # 归一化并记录对角元素 R[j, j] np.linalg.norm(v) if R[j, j] 1e-14: raise ValueError(f第{j}列与前序向量线性相关无法正交化) Q[:, j] v / R[j, j] return Q, R # 测试构造一个形状不那么好的矩阵 A np.array([[1.0, 2.0, 3.0], [0.0, 1.0, 4.0], [1.0, 0.0, 2.0]]) Q, R cgs(A) print(Q矩阵\n, Q) print(R矩阵\n, R) print(Q^T Q应为单位矩阵\n, Q.T Q)这段代码里有一个细节值得注意我在每列归一化前判断了范数是否接近0。实际工程中由于浮点误差真正的线性相关很少会表现为正好等于0而会表现为一个很小的数。如果没有这个检查后面所有计算都会被NaN或者巨大的数污染。用这个例子跑出来的Q列向量之间点积大约在10⁻¹⁶量级在double精度下可以认为完全正交。这也是CGS在低维度、良态矩阵下的真实表现——教科书里的公式不是没用只是有适用范围。3. 漂亮的公式为何在浮点世界失灵3.1 浮点数不是实数一次减法就能丢光精度问题出在哪核心在于计算机里的浮点数不是数学意义上的实数。double类型只有约15到16位十进制有效数字float32更少只有约7位。想象一个场景某个向量v₂在u₁方向上的投影分量是0.9999999999999995垂直于u₁的残余分量是0.0000000000000001。在数学的实数世界里这两个数清清楚楚减法后残余分量还在。但在float64的浮点世界里投影分量计算时末位本身就有舍入误差减去投影后得到的残余很可能只有前几位有效数字是可信的后面的精度已经在减法中被抹掉了。这就是所谓的灾难性抵消。两个极其接近的大数相减结果的有效数字位数大打折扣。正交化过程本质上就是反复在做减去投影这个操作一旦投影占主导、残余很小精度就开始崩坏。3.2 构造一个杀手级病态矩阵实测CGS百闻不如一见我用一个构造出来的病态矩阵实测一下CGS。这里的关键是让列向量之间高度接近从而制造大量灾滋性抵消。# 构造一个4x3的病态矩阵列向量近似相关 eps 1e-7 B np.array([ [1.0, 1.0, 1.0], [eps, 0.0, 0.0], [0.0, eps, 0.0], [0.0, 0.0, eps] ]) # 给矩阵加上一点点扰动确保严格列满秩 B B np.random.randn(4, 3) * 1e-12 Q_cgs, _ cgs(B) print(CGS的Q^T Q) print(np.round(Q_cgs.T Q_cgs, 8))我实际跑出来的结果令人印象深刻。Q^TQ矩阵的对角元素仍然是1但非对角元素已经偏离0很大了有的甚至到了10⁻³量级。注意这只是double精度下的表现。如果换成float32值会更离谱。打印一下对应矩阵的条件数会发现这个矩阵的条件数在1/eps10⁷量级。也就是说正交化后基向量的正交性误差大约是10⁻³左右比机器精度差了7个数量级。这就是CGS对病态矩阵的典型反应输出结果依然能归一化但正交性完全不可信。3.3 误差是怎么逐步放大的正交性崩溃机理为什么会有这么大的误差关键在于CGS的计算顺序。CGS在处理第j列时会先计算这个向量对前面所有列的投影系数R[i,j]ij这些系数是基于还没有被后续处理修正过的原始向量v_j算出来的。然后在第二次扫描中用算好的系数一次性减掉所有投影。问题在于当v_j与前面的uᵢ几乎平行时R[i,j]≈‖v_j‖而残余向量w的大小是‖v_j‖减去投影长度后的微小差额。在浮点数运算中这个差额的计算误差高达ε·‖v_j‖量级。更糟的是这个被污染了的残余w又会被当成后续第j1列投影时的基准。误差就这样一层一层往下传像滚雪球一样越滚越大。我在调试时还发现一个细节CGS对处理顺序极其敏感。同一组向量把顺序打乱重排正交化结果的正交性误差可能有数量级差异。这说明误差主要来自后续向量对已处理向量方向的重复投影——顺序越靠后的向量前面积累的误差就越多地传导到它身上。4. 修正Gram-Schmidt(MGS)改一处顺序就止血4.1 MGS和CGS的本质区别边算边修正修正Gram-SchmidtModified Gram-Schmidt简称MGS和CGS在数学上完全等价。注意是数学意义上完全等价在无限精度的实数运算下两者输出一模一样。但MGS在浮点世界里的表现好得多原因在于它改变了计算顺序。CGS的做法是第j列处理时先用原始v_j把所有投影系数算完再一次性和减掉。而MGS的做法是每处理完一个基向量uᵢ立刻用它去修正所有还没处理过的向量把它们在uᵢ方向上的分量当场剥掉。这里的直观区别可以类比成两种打扫房间的方式。CGS是先把整个房间的物品列个清单最后集中扔一次垃圾MGS则是每路过一个角落就顺手收拾掉。后者虽然看起来工作量一样但因为每一步都即时清理后面再判断某个向量时看到的已经是剔除干净的状态而不是夹杂着杂质的状态。4.2 MGS的Python实现与正交性实测直接把上一章的CGS代码改一改就能得到MGSdef mgs(A): 修正Gram-Schmidt正交化 数学上与cgs等价但浮点表现显著更优 m, n A.shape A A.astype(float).copy() Q np.zeros((m, n)) R np.zeros((n, n)) for i in range(n): # 归一化当前列作为新的正交基向量 R[i, i] np.linalg.norm(A[:, i]) if R[i, i] 1e-14: raise ValueError(f第{i}列与前序向量线性相关) Q[:, i] A[:, i] / R[i, i] # 关键区别立即用Q[:, i]修正所有后续列 for j in range(i 1, n): R[i, j] Q[:, i].dot(A[:, j]) A[:, j] A[:, j] - R[i, j] * Q[:, i] return Q, R就这一处顺序调整——把先算完所有投影再减改成算一步减一步——数值稳定性天差地别。用和上一章节一模一样的病态矩阵B测试MGS得到的Q^TQ的非对角元素CGS是10⁻³量级MGS能压到10⁻¹⁴量级逼近机器精度极限。这个差别是惊人的。同样是数学上等价的公式在浮点世界里一个接近崩溃一个优秀得几乎可以投入实际使用。难怪MGS是很多教科书和工程指南里推荐的手写实现方案。4.3 为什么MGS的残余误差更小误差传播路径对比MGS的优势本质上来自一个改变它确保每次做减法时作为投影基准的Q[:, i]与当年那个已经被清理过的向量A[:, j]之间的相互作用是逐步完成的。在CGS里A[:, j]第一次被使用时是原始状态里面有大量前面几个方向的成分。一次性减掉所有这些分量残余值本身很小相对误差就大。而在MGS里A[:, j]是先被u₁剥掉一层然后这个已经纯化过的剩余向量再被u₂剥掉一层。每一步减法处理掉的都是当时残余向量里的主要分量而不是原始向量里的主要分量。残余向量每一步都在变小但减去的分量和残余的量级始终匹配不发生极端的大数相减。我在论文里见过一个更定量的结论假定矩阵A的条件数为κ那么CGS得到的Q的正交性误差大致正比于ε·κ而MGS正比于ε·κ²不对——让我核对一下。更准确的经典结论是CGS的正交性误差正比于ε·κMGS也正比于ε·κ但CGS还有一阶、二阶误差项。这些细节不必死记工程上记住结论就好MGS与CGS的差距在矩阵病态时会呈数量级的拉大。5. 工业界真正常用的Householder QR以及你该怎么选5.1 Householder的思路用反射矩阵一次性消灭非对角线元素既然MGS已经这么好了为什么我还要提Householder因为现代数值计算库在计算QR分解时几乎不用Gram-Schmidt家族的算法而是用Householder变换。成年人只做选择好的工程师得知道教科书给的答案和工作用的答案为什么不一样。Householder的思路和Gram-Schmidt完全不同。Gram-Schmidt是一列一列地剥离投影最后把Q矩阵显式地构造出来。Householder则是通过一系列正交反射矩阵逐个把矩阵对角线下方的元素消成0最终把矩阵化为上三角形式R。每步构造的反射矩阵Hᵢ是正交的把它们连乘起来就得到了隐式的Q。构造方法是给定向量x想把它映射到与某个单位向量e₁同向的方向就让反射发生在x与目标方向夹角的平分面上。反射矩阵H I - 2uuᵀ/(uᵀu)其中u x ± ‖x‖e₁。在实现时符号选择有个细节为了数值稳定性取x₁的相反符号作为反射方向避免u中发生灾难性抵消。Householder的最大优势是数值稳定性极佳它的正交性误差通常正比于ε与矩阵条件数无关。这也是为什么LAPACK、NumPy、MATLAB、R等主流数值计算库的qr()函数底层清一色是Householder或它的变种。5.2 三种QR算法对比表下面这张表是我自己用的时候会参考的速查表收在这里算法运算量数值稳定性是否显式给出Q适用场景CGS约2mn² flops差误差正比于ε·κ是可直接取列教学演示、低维良态矩阵MGS约2mn² flops中上误差正比于ε·κ是可直接取列需要显式Q且矩阵不太病态Householder约2mn² - 2n³/3 flops极佳误差正比于ε否Q隐含在反射矩阵连乘中通用工业级QR分解从这张表可以看出MGS和Householder的运算量相当但稳定性的上限不同。MGS的优势在于能直接拿到Q的列向量省去显式化Q的成本Householder的优势在于稳定性好但如果你要的是显式的Q矩阵需要额外累积反射算子增加一些计算量。所以我给朋友的排查建议是如果你的代码是在生产环境里跑、数据精度未知、矩阵规模较大直接用np.linalg.qr()那是经过数十年优化的工业品不要重造轮子。这也是我们后来把点云程序改成用numpy内置QR后问题立刻消失的原因。5.3 学Gram-Schmidt时容易被忽略的关联点虽然工程上不常手写Gram-Schmidt做QR分解但Gram-Schmidt家族的思想在其他领域有着Householder无法替代的价值。比如机器学习里的注意力机制和表示学习经常需要对特征向量做正交化约束。这时你面对的不是方阵QR分解而是一个持续更新的向量流——来一个新向量就要和已有基向量正交化一次。这种增量式的场景里MGS的边采边修正思想比Householder的批量变换更合适。再比如Krylov子空间方法如GMRES、Arnoldi算法每一步迭代都产生一个新向量必须和之前积累的基向量正交化。这些方法的工业实现里用的正是MGS或者它的加强版如迭代修正的MGSDGKS。所以不要觉得学Gram-Schmidt是浪费时间它是连接线性代数理论和迭代算法实操的一座桥。6. 真实项目中的选型与避坑清单6.1 数值计算与数学库内部请交给Householder如果你是做数值计算、物理仿真、统计建模这类对结果精度要求极高的领域我的建议很简单不要手写正交化。numpy.linalg.qr、scipy.linalg.qr、MATLAB的qr、R的qr函数这些正规军背后是LAPACK里成熟的Householder实现不仅有反射矩阵还做了分块优化、排序策略等一系列工程优化。手写CGS或MGS或许能让你在课堂上拿高分但在生产环境里随便一个病态矩阵就能让结果翻车。我在排查朋友的点云配准问题时第一反应不是去优化他的CGS代码而是直接查他有没有用内置库。他一脸意外觉得自己的算法更可控。但工程经验告诉我数值算法这个领域经过几十年验证的库函数几乎总是优于自己拍脑袋写的版本。信科学用库省心。6.2 机器学习与数据分析特征正交化的正确姿势机器学习和数据分析里有一个高频需求把高维特征向量变成正交的以消除共线性。比如在做线性模型时如果两个特征高度相关系数估计会非常不稳定。这个场景要分情况看。如果你的目标是对特征矩阵做降维、去相关标准的做法不是Gram-Schmidt而是PCA/SVD。SVD在处理数值稳定性上比Gram-Schmidt更全面因为它不需要矩阵是方阵也不要求列满秩还能顺便给出奇异值分布供你判断保留多少维度。如果确实需要把一小组向量正交化并且向量本身已经做了中心化或白化可以用MGS。但有一个额外建议在做正交化之前先计算一下向量组的条件数或者检查两两夹角。如果发现两个向量夹角小于1度无论是CGS还是MGS结果的可信度都要打问号。这时候更好的做法是先降维去掉冗余向量再正交化。具体到Embedding向量的去相关场景我见过不少团队直接用CGS处理词向量矩阵的列然后发现下游任务的指标掉了。这通常是数值稳定性问题换成MGS或SVD白化后指标就恢复了。如果你在项目里也遇到类似情况建议先检查一下处理前后的正交性指标用Q^TQ与单位矩阵的Frobenius范数偏差来量化。6.3 图形学与游戏开发三维向量正交基构建的口诀图形学领域有个高频子问题给定一个法线向量n如何快速构造一组标准正交基切向量、副切向量用于切线空间法线贴图采样或者相机坐标系构建这个场景用不到完整的Gram-Schmidt只需要一个小技巧。我惯用的做法是先找一个与n肯定不平行的辅助向量。为了避免退化辅助向量选择n中分量绝对值最小的坐标轴方向。如果n(0.577,0.577,0.577)三个分量绝对值差不多随便选一个比如(0,0,1)。然后用叉积生成第一个基向量t normalize(cross(aux, n))再用第二个叉积生成b cross(n, t)。由于t与n正交、b与n和t都正交就得到了标准正交基。如果因为某些原因需要把一组三维向量整体正交化且向量数量大于3就要回到MGS的思路但三维空间的维数上限决定了超过3个向量必然线性相关这时候需要先做PCA筛选。图形学里还有一个常见陷阱float32下做多次矩阵累乘后正交基会漂移。我见过有的引擎每帧都对相机矩阵做一次正交化用MGS一步到位开销极低但能保持长期稳定。6.4 我的个人检验清单踩过这么多次坑后我给自己列了一份正交化相关任务的检验清单每次写代码都会对照一遍先量化不正交有多严重处理前打印矩阵条件数或列向量最大夹角判断是否有必要做正交化。float32下默认不信CGS如果用float32计算优先MGS或Householder如果必须用CGS一定要增加事后检查。事后验证Q^TQ与单位矩阵的偏差计算‖QᵀQ-I‖的Frobenius范数或最大绝对值这个指标能直接反映正交化质量。归一化前检查残余长度如果某个向量减去投影后模长小于设定的阈值我常用1e-10果断报告异常不要硬归一化。高维场景避免手写调用稳定库维度超过几十、矩阵性质未知时优先numpy.linalg.qr或其他成熟库。这份清单看起来简单但每条背后都有一次真实的血泪教训。尤其是第一条太多人拿到向量就无脑正交化根本不看输入数据的质量结果算出一堆数学上错误但代码不报错的数字比直接报错更难排查。回忆起朋友那个点云配准项目最后就是把CGS换成了np.linalg.qr程序在float64下跑了一整宿都没再出问题。后来我在自己处理三维重建里的坐标系恢复时也养成了先用矩阵条件数探路的习惯。如果你看完这篇笔记只带走一句话那就是在任何数值计算里先怀疑自己的实现再怀疑库函数然后永远用数据验证。