前两天帮一个做数据建模的朋友看代码,他截了张图问我:同一个矩阵,为什么他本地跑出来秩是 5,放到服务器上跑出来却是 6?我把矩阵调出来扫了一眼就明白了——里面有一列是另外两列的线性组合,只是因为数据在导出时做过四舍五入,理论上严格为零的那个位置实际存的是 1e-9 量级的残差。这就是典型的数值秩问题。计算矩阵的秩这件事听起来是线性代数课本第一章的内容,一行一行消元、数一数主元就完事了,可一旦把它搬到计算机上、搬到真实数据上,这个整数就开始变得模糊。不是数学定义有问题,而是浮点表示、消元顺序和阈值判定在一起搅局。
我写这篇东西,是想把"算秩"这件小事从黑板拉到工位上。它解决的问题非常具体:判断一组向量到底有几个是独立的、判断一个线性方程组是有唯一解还是无穷多解还是无解、判断一批特征里有多少是冗余的、判断一个系统模型是不是能控能观。做数据分析的、做数值计算的、做控制工程的、做图像压缩的,甚至只是想把线性代数作业写对的学生,都会用到。文章里既有手算的完整推导,也有可以直接复制运行的代码,还有一堆我在实际项目里踩过的坑——尤其是最后那部分关于"同一个矩阵算出两个答案"的排查记录,如果你正在被类似问题困扰,那几段值得反复看。
1. 矩阵的秩到底在算"什么":先把它理解成有效信息量
1.1 一堆问卷里到底有几个独立问题
我喜欢用问卷来类比。假设你收集了一份问卷,上面有 200 个问题,但你仔细一看,第 3 题是第 1 题和第 2 题的加和,第 50 题是第 3 题乘以 2,第 120 题又是前面某几题的重新组合。那么这 200 个问题里真正携带独立信息的,可能只有 20 个——剩下的都是这 20 个的"衍生品"。矩阵的秩,说的就是这个"真正独立的行(或列)数量"。
把矩阵看成一组行向量排在一起,秩就是这组向量张成的空间的维数。如果第 3 行能被第 1、2 行线性表示出来,那它就没贡献新的维度,秩不会因为它的存在而增加。这一点很关键:秩刻画的是整个向量组的表达能力上限,而不是元素个数。一个 1000×1000 的矩阵,秩可能只有 3,意味着它看上去装了一百万个数,实际上所有信息都压缩在三个方向的组合里。反过来,一个 3×1000 的矩阵,秩最多只有 3,因为行数摆在那儿——秩永远不超过行数和列数中的较小者。
理解了这一层,很多结论就变得非常自然:秩为 n 的 n 阶方阵必然可逆(每一行都贡献了新维度,没有冗余),秩小于 n 就不可逆(存在冗余,存在非零向量被映射到零)。所谓"奇异矩阵",本质上就是"信息有重复、维度塌陷"的矩阵。这个直觉在后面的算法选择里会反复用到。
1.2 三个等价定义,对应三条算法路线
教科书上通常给出三种等价的定义,我在实际工作中发现,每个定义都直接对应一种实现路径,理解它们的差异比记住定义本身更重要。
第一种是行阶梯形定义:把矩阵做初等行变换化成阶梯形,非零行的数量就是秩。这条路线对应高斯消元法,是手算的标准流程,也是大多数教学场景采用的路径。
第二种是行列式定义(针对方阵):n 阶方阵的秩等于它所有非零子式的最高阶数。换个说法,秩 r 意味着存在一个 r 阶子式不为零,而所有 r+1 阶子式都为零。这条路线在理论上很干净,但计算上极其糟糕——一个 10 阶矩阵要检查的子式数量是天文数字,只能用于极小规模或者理论证明。
第三种是奇异值定义:矩阵的秩等于它非零奇异值的个数。这条路线对应奇异值分解(SVD),是数值计算中最稳健的做法。它的好处是把"是否为零"这个问题从原始元素层面转移到了奇异值层面,而奇异值对扰动是有序的、可量化的,这就让"数值秩"的判定有了依据。
我在选型时的基本原则是:手算和教学用手法一,理论推导用手法二,工程代码用手法三。而实际写代码时,稍微大一点的矩阵一律走 SVD,除非有明确的性能约束或其他特殊需求。原因在第四节会详细讲。
1.3 秩亏意味着什么:从解方程的角度看
秩的另一个高频用法是判断线性方程组 Ax = b 的解的结构。设 A 是 m×n 矩阵,b 是 m 维向量,那么:
- 若 rank(A) = rank([A|b]) = n,方程有唯一解;
- 若 rank(A) = rank([A|b]) < n,方程有无穷多解,自由度为 n - rank(A);
- 若 rank(A) < rank([A|b]),方程无解。
这里 rank([A|b]) 是把 b 作为增广列拼进去之后的秩。这个判据看起来简单,但里面藏着一个很实用的观察:增广之后秩增加了,说明 b 引入了新的维度,也就是 b 落在了 A 的列空间之外。工程上碰到"最小二乘解残差特别大"的情况,八成就是这个原因——你的模型根本表达不了这批数据,不是参数没调好,是模型结构不对。
我自己在做一个传感器标定的小工具时就吃过这个亏。当时用一组标定数据去解方程,残差一直很大,我以为是噪声问题,反复清洗数据都没用。后来算了增广矩阵的秩才发现,理论上 rank(A) 应该是 3,实际算出来是 4 和 3 之间反复横跳——增广列确实带来了微小但稳定的新方向。原因是一个传感器的量程被设错了,导致数据本身违反了物理约束。秩在这里起到的作用,相当于一个"结构体检",比看残差曲线直观得多。
2. 手算路线:初等行变换、主元与消元计数
2.1 行阶梯形不是唯一的,但秩是唯一的
初等行变换有三种:交换两行、某行乘以非零常数、某行加上另一行的倍数。这三种操作都不改变矩阵的秩,这是整个消元法的理论基础。但要注意一个细节:化简得到的行阶梯形不唯一,不同的人用不同的消元顺序会得到不同的阶梯形,但非零行的数量一定相同。这个"不唯一但秩唯一"的性质,恰好也是后面数值算法里"结果可以浮动但不应大幅浮动"的伏笔。
判断阶梯形的标准有三条:所有非零行都在全零行之上;每行第一个非零元素(主元)所在的列,比上一行主元所在的列更靠右;主元下方全为零。满足这三条之后,数一数有几个非零行,秩就出来了。
我在教别人手算时,最常提醒的一点是:不要死盯着对角线。很多教材的例子都是方阵,主元刚好在对角线上,容易形成"主元一定在对角线"的错误直觉。一旦碰上非方阵,或者中间出现零主元需要换行,这个直觉就会崩。正确的习惯是始终记住"主元是当前行最左边的非零元素",位置随行而变,不固定在对角线上。
2.2 手算一个 3×4 矩阵,把每一步摊开
拿一个具体的例子走一遍,比看定义管用得多。设
A = [ 1 2 3 4 ] [ 2 4 6 9 ] [ 3 6 9 13]
第一步,用第一行消去下面两行的第一列。第二行减去第一行的 2 倍,得 [0, 0, 0, 1];第三行减去第一行的 3 倍,得 [0, 0, 0, 1]。此时矩阵变为
[ 1 2 3 4 ] [ 0 0 0 1 ] [ 0 0 0 1 ]
第二步,用第二行消去第三行。第三行减去第二行,得 [0, 0, 0, 0]。矩阵变为
[ 1 2 3 4 ] [ 0 0 0 1 ] [ 0 0 0 0 ]
现在数非零行,是 2 个,所以 rank(A) = 2。
这个例子里有两个观察点值得停一下。第一,第二列和第三列虽然看着有数值,但它们都是第一列的倍数关系(第 2 列 = 2×第 1 列,第 3 列 = 3×第 1 列),所以在消元过程中整个列都被"清空"了——这直观地展示了"线性相关列的贡献会被抵消"。第二,第四列的第一行是 4,第二行是 9,第三行是 13,差值分别是 5 和 4,不成比例,所以它贡献了独立的维度。最终秩为 2,意味着这个 3×4 矩阵的所有列都落在由两个方向张成的平面里。
再看一个稍微绕一点的例子,专门用来演示换行。设
B = [ 0 1 2 ] [ 1 2 3 ] [ 2 4 6 ]
第一列第一个元素是 0,不能做主元,先交换第一、二行:
[ 1 2 3 ] [ 0 1 2 ] [ 2 4 6 ]
用第一行消第三行,第三行减第一行的 2 倍得 [0, 0, 0]。再用第二行消,得
[ 1 2 3 ] [ 0 1 2 ] [ 0 0 0 ]
秩为 2。这里如果一开始不做换行,直接拿 0 去当主元除,整个流程就崩了。这就是"部分主元法"的雏形:选主元时优先挑绝对值大的,或者至少保证不为零。这个习惯在纸上算的时候显得多余,在代码里就是生死攸关的一条纪律。
2.3 分数爆炸问题与"整数保底"技巧
手算矩�阵最容易翻车的地方是分数。上面两个例子碰巧都很干净,一旦碰到主元是 3、而下面某行是 5 的情况,分数就开始滚雪球,算到后面自己都不信自己的结果。我常用的两个对策:
第一个是整数保底法。在每一步消元之前,观察目标行的系数,尝试用行变换把分数避开。比如要消去 (2,1) 位置的 5,主元是 3,正常做法是第二行减去第一行的 5/3 倍;但你可以先把第二行乘以 3,再做减法,这样就全程在整数域内操作。行乘以非零常数不改变秩,所以这个操作完全合法。代价是数值会变大,但手算时数字大一点总比分数链好核对。
第二个是分层记录法。把每一步的矩阵写在单独一行,并且用箭头标注"r2 - 2r1"这样的操作说明。听起来很啰嗦,但当你在第 5 步发现结果不对劲需要回溯时,有操作记录的草稿能救命。我自己算 4 阶以上的矩阵,习惯把每一步的变换写在右侧空白处,最后检查时从后往前逆推一遍,能抓出大部分算术错误。
提示:手算时如果某一步出现了非整数,不要硬着头皮往下算,先回头看看是不是可以用"先乘后减"的方式把分数消掉。分数链一旦超过两层,出错概率会陡增。
还有一个容易被忽略的点:行变换虽然不改变秩,但会改变行列式的值。如果同一个矩阵你既要算秩又要算行列式,别混在一起做——算秩的过程中一旦用了"某行减去另一行的倍数",行列式不变;但如果你用了"交换两行",行列式变号;如果用"某行乘 k",行列式乘 k。秩不受影响,行列式可就面目全非了。这个坑我在给学生改作业时见过太多次,两个问题混在一张草稿纸上做,最后两个答案都错。
3. 代码路线:从一行调用到自己实现
3.1 现成函数怎么用才不出错
绝大多数场景下,你不需要自己写消元。科学计算生态里已经有成熟的秩计算函数,Python 里最常见的是numpy.linalg.matrix_rank。它的用法极简:
import numpy as np A = np.array([[1, 2, 3, 4], [2, 4, 6, 9], [3, 6, 9, 13]], dtype=float) r = np.linalg.matrix_rank(A) print(r) # 2但这行调用里藏着一个关键参数:tol(容差)。如果不显式指定,函数会根据矩阵的最大奇异值和机器精度自动推算一个默认阈值,大致形式是S.max() * max(M, N) * eps,其中S.max()是最大奇异值,M、N是矩阵维度,eps是浮点机器精度(双精度约 2.22e-16)。这个默认值在大多数情况下是合理的,但它有一个隐含假设:矩阵的各个方向量级大致相当。
一旦你的数据里存在量纲差异巨大的列——比如一列是电压(量级 1),一列是电阻(量级 1e6)——这个默认阈值就可能把本该保留的维度判成零,或者把本该丢掉的噪声判成有效维度。我处理这类数据时的标准动作是先做列归一化,再算秩:
def rank_normalized(A): A = np.asarray(A, dtype=float) norms = np.linalg.norm(A, axis=0) norms[norms == 0] = 1.0 return np.linalg.matrix_rank(A / norms)归一化之后每一列的范数都是 1,量级被拉平,默认阈值的假设就成立了。这是一个非常小但极其实用的改动,我在做传感器数据融合时基本是标配。
3.2 自己写一版带部分主元的消元
有些场景必须自己实现:比如需要在嵌入式环境里跑、需要中间过程的副产物、或者需要控制每次消元的顺序。下面是我常用的一个版本,带部分主元选择(partial pivoting)和显式容差:
def rank_gauss(A, tol=1e-10): """用带部分主元的高斯消元计算矩阵的秩""" A = np.array(A, dtype=float, copy=True) m, n = A.shape rank = 0 for col in range(n): # 在当前列中找绝对值最大的行作为主元 pivot_row = rank + np.argmax(np.abs(A[rank:, col])) if abs(A[pivot_row, col]) <= tol: continue # 这一列在主元行以下全为零,跳过 if pivot_row != rank: A[[rank, pivot_row]] = A[[pivot_row, rank]] # 用主元行消去下方所有行 factors = A[rank + 1:, col] / A[rank, col] A[rank + 1:, col:] -= factors[:, None] * A[rank, col:] rank += 1 if rank == m: break return rank这段代码有几个设计决定值得解释。
第一,外层循环遍历的是列,而不是行。这是处理非方阵的关键:如果你按行循环,一旦遇到某行的主元位置已经被前面的列占满,逻辑就会混乱。按列遍历、用一个独立的rank变量记录当前已经找到的主元数,逻辑上天然正确,而且能自动处理"某列全零"的情况。
第二,pivot_row的搜索范围是A[rank:, col],即从当前主元行往下找,而不是整列找。这保证了已经处理好的主元行不会被破坏。
第三,也是最重要的一点,判断阈值用的是绝对的tol,而不是相对阈值。这在做演示时没问题,但用在真实数据上是有风险的,具体原因在下一节展开。
我实测过这个函数和np.linalg.matrix_rank在随机矩阵上的一致性,1000 次随机测试(维度 2 到 20,元素服从标准正态分布)中,两者的结果完全一致。但在构造的病态矩阵上,差异就出来了,这正好引出下一节的讨论。
3.3 大矩阵与稀疏矩阵的现实取舍
规模上去之后,策略要变。几百乘几百的稠密矩阵,SVD 的复杂度是 O(mn·min(m,n)),在现代机器上还是毫秒级的,随便算。上千维之后,如果你的矩阵是稠密的,SVD 可能就要几秒到几十秒,这时候可以考虑几个替代方案。
第一个方案是用随机化 SVD。它的思路是先做一个低维投影,把大矩阵压到远小于原尺寸的子空间里,在子空间上做精确 SVD,再映射回来。对于秩远小于维度的矩阵,这个方法的精度损失很小,速度提升却可以是数量级的。
第二个方案是走QR 分解。带列主元的 QR 分解能把矩阵按"信息量"从大到小排序,然后看 R 矩阵对角线上的元素,从大到小数到某个阈值为止,那个位置就是数值秩。这个方法在只关心秩、不关心奇异值本身时,比完整 SVD 更快。
第三个方案适合超大稀疏矩阵:用迭代法估计,比如基于随机向量的迹估计。这类方法的思路是不精确算出所有奇异值,而是通过若干次矩阵-向量乘法去逼近"有多少个显著奇异值"。精度不如 SVD,但代价极低,在推荐系统、图计算这类场景里很常用。
选型时我的经验是:矩阵能整体装进内存、维度不超过几千,直接用 SVD 别犹豫;超过这个规模、且矩阵稀疏,优先考虑迭代估计;介于两者之间的,QR 带列主元是个不错的折中。没必要为了性能过早优化,绝大多数业务场景的数据规模根本到不了需要考虑这一步的量级。
4. 数值秩:为什么同一个矩阵会算出两个答案
4.1 奇异值、条件数与误差放大
回到开头那个问题:为什么同一个矩阵在两台机器上算出不同的秩?答案通常在奇异值的分布上。矩阵的奇异值是一组非负数,按从大到小排列。理论上秩就是非零奇异值的个数,但计算机里的浮点数无法表示"无限接近零但不等于零"的数,任何一次运算都会引入相对误差,量级在 1e-16 左右。当某个奇异值本身就接近这个量级时,它究竟被算成"零"还是"非零",就取决于具体实现、求和顺序、甚至编译器的优化选项。
更麻烦的是病态问题。矩阵的条件数定义为最大奇异值与最小非零奇异值之比。条件数是 1e12 的矩阵,意味着输入数据里 1e-12 的相对扰动,在输出端会被放大到 1 的量级。这种放大作用会直接污染最小那几个奇异值,让它们变得完全不可信。我处理过一个协方差矩阵,条件数接近 1e15,用不同库算出来的秩能差三四个,这时候"真实秩是多少"这个问题本身已经没有意义了——数据里根本不存在那么多有效信息。
一个实用的判断方式是画出奇异值的衰减曲线。如果曲线在某个位置出现明显的"断崖",断崖之前的数量就是秩;如果曲线是平缓衰减、逐渐没入噪声底,那就不存在清晰的秩,只能给定一个阈值说"在这个精度下认为秩是多少"。我习惯的做法是把奇异值和机器精度乘上最大奇异值的那个阈值画在同一张图上,看它们在哪里交叉。
4.2 三种方法横向对比
把常用的几种方法放在一起,差异就很清楚了:
| 方法 | 计算复杂度 | 数值稳定性 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 行列式子式法 | 指数级 | 理论上精确 | 极小规模、理论推导 | 规模稍大就完全不可算 |
| 高斯消元计数 | O(mn·min(m,n)) | 中等,依赖主元策略 | 手算、教学、小规模稠密 | 无主元选择时误差累积严重 |
| QR 带列主元 | O(mn·min(m,n)) | 较好 | 中等规模、需要排序 | 阈值选择敏感 |
| 完整 SVD | O(mn·min(m,n)) 但常数大 | 最好,奇异值有序 | 工程默认选择 | 大矩阵上耗时明显 |
| 随机化方法 | 与目标秩相关 | 中等到较好 | 超大规模、低秩结构 | 需要预估秩,精度有损 |
这张表里我想强调一点:复杂度的理论值看起来差不多,常数差异却很大。高斯消元和 QR 在同一量级,但 SVD 的常数可能是前者的五到十倍。所以"能不能用消元代替 SVD"这个问题,答案取决于你的数据有多干净。数据干净、量级统一,消元完全够用;数据脏、量级混乱,省下来的那点时间还不够你排查一个错误结果的。
4.3 阈值到底该定多少
这是被问得最多的问题,也是最没有标准答案的问题。我的思路是分三步走。
第一步,先看奇异值的绝对量级分布。把奇异值打印出来,看最大值和最小值差多少个数量级。如果差了 15 个数量级以上,基本可以确定最小那几个是数值噪声,不用纠结。
第二步,用相对阈值而不是绝对阈值。最常用的形式是tol = max(m, n) * eps * S_max,其中eps是机器精度,S_max是最大奇异值。这个形式的理由是:误差在消元过程中最多累积到与维度和机器精度成正比的量级,所以阈值也应该按这个量级设。这也是主流库默认采用的形式。
第三步,在业务层面确认这个阈值是否合理。这一步最容易被跳过,但它往往才是关键。举个具体的例子:如果你的矩阵来自一组物理测量,而测量设备的精度只有三位有效数字,那么任何小于最大奇异值 1e-3 倍的奇异值,在物理上都不应该被当作有效信息——不是算法算不准,是你的数据根本支撑不起这个精度。这时候应该把阈值设在 1e-3 的相对量级,而不是死守机器精度的默认值。
我在一个图像处理的场景里就把这个思路用得很实:对图像块做低秩分解时,用机器精度阈值算出来的秩几乎是满秩的,因为图像里有大量微小噪声;改成按视觉可辨的误差容限来设阈值之后,秩降到了原来的三分之一,压缩比上去了,画面肉眼几乎看不出差别。阈值不是一个数学参数,它是一个业务参数,这句话我常跟团队里的人讲。
5. 排错实录与常见问题速查
5.1 问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 同一矩阵不同环境算出不同秩 | 奇异值处于阈值附近 | 打印全部奇异值,看最小的几个的量级 | 显式指定 tol,或做归一化 |
| 秩等于 min(m, n),且数据是测量值 | 未归一化或噪声过大 | 检查各列量级是否悬殊 | 列归一化后再算 |
| 秩远小于预期 | 阈值过大,或存在近共线列 | 看奇异值衰减曲线 | 调小相对阈值,检查共线来源 |
| 手算结果比代码小 | 消元时误把相关行当独立行 | 重做一次,标注每步变换 | 使用整数保底法重算 |
| 方阵满秩但求逆报错 | 条件数过大,数值上接近奇异 | 算条件数 | 改用伪逆或正则化 |
| 消元过程出现 NaN | 主元为零时做了除法 | 检查是否启用部分主元 | 加入主元选择逻辑 |
| 稀疏矩阵算出满秩 | 零元素被浮点噪声填充 | 检查稀疏结构是否被破坏 | 使用稀疏专用算法 |
5.2 几个印象比较深的坑
第一个坑是关于数据类型的。有一次我用一个整数类型的数组去算秩,结果一切正常,直到某天数据里混进了一个小数,整个数组被静默转成了浮点,然后秩就变了。原因是整数运算和浮点运算的消元路径完全不同:整数相减不会产生舍入误差,而浮点会。这不是说整数更好——整数会溢出,而且遇到真正的分数就无解。我的做法是一律在入口处显式astype(float),让整个流程的数值行为统一可预期,而不是依赖隐式转换。
第二个坑是关于行列顺序的。有一次做特征筛选,我把特征按字母序排了一遍,算出来的秩和按原始顺序算的不一样。听起来很离谱,但原因很朴素:不同的列顺序会导致消元时主元的选择不同,误差累积路径也就不同,在病态矩阵上就表现为秩的差异。这个现象提醒我,对于条件数大的矩阵,"秩"这个数值本身就不该被当作一个绝对可信的整数,而应该当作一个带不确定度的估计。后来我的做法是:对同一矩阵随机打乱列顺序算十次,如果结果不稳定,就说明数据本身有问题,先回去清洗数据,而不是纠结于哪个数字是对的。
第三个坑是关于内存布局的。这个坑比较隐蔽,用 C 顺序存储和用 Fortran 顺序存储的同一个矩阵,在某些底层实现里计算路径不同,累加求和的顺序不同,最后的奇异值会有末位差异。绝大多数时候这个差异不影响秩,但当奇异值正好卡在阈值上的时候,它就足以翻转结果。我的应对方式很土但有效:永远不要在阈值边缘做判断。如果算出来的最小有效奇异值和阈值只差一两个数量级,那就说明这个维度本身就是可疑的,与其纠结算成几,不如把它标记为"不确定",在后续处理里单独对待。
提示:如果你在线上系统里做秩判定,务必把阈值和奇异值分布一起打日志。只看一个整数结果,出问题时完全无从排查。这是我花了一个通宵才换来的教训。
6. 秩在真实场景里怎么用:几个具体落点
6.1 多重共线性检测
做回归建模时,如果设计矩阵 X 的列之间存在近似的线性关系,参数估计就会不稳定,系数的符号甚至可能和常识相反。判断方法之一就是算 X 的秩:如果 rank(X) < 列数,说明存在严格共线;如果秩等于列数但条件数很大,说明存在近似共线,同样有风险。
实操上我一般会组合使用:先算秩做粗筛,再算条件数做细判,最后看方差膨胀因子定位到具体的列。秩的作用是给出一个"有没有问题"的快速信号,成本极低,适合放在数据检查流程的最前面。一旦秩报警,再去做更细致的诊断,比一上来就跑全套共线性分析要高效得多。
6.2 最小二乘与伪逆
当方程 Ax = b 无解时,我们退而求其次找最小二乘解;当 A 秩亏、有无穷多解时,我们想找范数最小的那个。这两个需求都可以用伪逆统一处理,而伪逆的构造直接依赖秩:把奇异值分解 A = UΣVᵀ 之后,把 Σ 里的非零奇异值取倒数、零奇异值保持为零,再乘回去就是伪逆。
这里的"零"怎么判,又回到了阈值问题。实践中我习惯显式设一个相对阈值,把小于tol * S_max的奇异值直接置零,而不是依赖库的默认行为。这样做的好处是行为可复现——换台机器、换个库版本,结果不会突然变化。对于需要长期维护的代码,这种确定性比省一行代码重要得多。
6.3 低秩近似与数据压缩
图像、推荐系统的评分矩阵、文本的共现矩阵,都存在明显的低秩结构。所谓低秩近似,就是只保留前 k 个奇异值及其对应的奇异向量,把剩下的丢掉。k 取多少,本质上就是在问"这个矩阵在某个精度下的秩是多少"。
我在处理一批图像块时用过这个思路:对每个块做 SVD,按能量占比保留前 k 个分量,k 由"保留 99% 的奇异值平方和"这个准则自动确定。实测下来,纹理简单的区域 k 只有三到五,纹理复杂的区域 k 能到二十以上。这个自适应的 k 值分布本身就是一个很有用的特征——它比人工设计的纹理描述子更直接地反映了局部的复杂度。
6.4 系统分析中的秩判据
在动态系统的分析里,能控性和能观性都有基于秩的判据:构造能控性矩阵,如果它的秩等于状态维数,系统就是能控的。这个判据在理论上是精确的,但在数值上同样是阈值问题。工程上我一般不会只看秩,而是同时计算能控性矩阵的奇异值,用最小奇异值与最大奇异值的比值作为"能控程度"的度量。比值接近 1 说明控制得很轻松,比值小到 1e-8 以下基本就等于不可控了,中间地带则需要结合实际控制精度来判断。
这个做法背后的思路,其实和前面反复强调的一样:秩是一个二值化的结论,而现实世界是连续渐变的。与其纠结一个整数是 3 还是 4,不如去看那个决定它是 3 还是 4 的量到底有多大、离边界有多远。这个习惯一旦养成,你会发现很多"算法不稳定"的问题,本质上都是"问题本身处在边界上"。
最后分享一个我自己常用的小工具函数,把奇异值、阈值、秩三样东西一次性打出来,排查问题的时候特别顺手:
def rank_report(A, rel_tol=1e-10): A = np.asarray(A, dtype=float) S = np.linalg.svd(A, compute_uv=False) tol = rel_tol * S[0] if S.size else 0.0 rank = int(np.sum(S > tol)) print(f"shape={A.shape}, rank={rank}, tol={tol:.3e}") print(f"cond={S[0]/S[-1]:.3e}" if S[-1] > 0 else "cond=inf") for i, s in enumerate(S): flag = "keep" if s > tol else "drop" print(f" s[{i}] = {s:.6e} {flag}") return rank每次结果不对劲,跑一遍这个函数,看看到底是哪个奇异值在阈值附近晃,问题基本就定位了一半。剩下的那一半,通常得回去看数据本身——这是我这些年算下来最深的一个体会:秩算不准的时候,十次里有八次不是算法的问题,是数据的问题。