ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

马氏距离实战:协方差矩阵、Cholesky与异常检测避坑

马氏距离实战:协方差矩阵、Cholesky与异常检测避坑

1. 屏幕前的距离陷阱:欧氏距离为什么会先倒下

做数据清洗和异常筛查的朋友大概都遇到过这种场面:一批二维或三维的传感器读数摆在面前,你想找出最"离群"的那几条记录,最省事的做法就是算每个点到均值的欧氏距离,然后按大小排序。第一次这么干通常没问题,第二次就会开始怀疑人生——明明某个点在业务意义上更离谱,排序却把它排在了中间。

问题不在计算,在于欧氏距离默认了所有维度是同一把尺子、且彼此毫不相干。而现实数据里,两个特征的相关性往往强得惊人,量纲更是千差万别。马氏距离(马哈拉诺比斯距离,Mahalanobis Distance)就是专门为修正这两件事而生的度量方式。它把"协方差结构"塞进了距离公式里,让距离衡量的是"在数据分布的形状下,这个点偏离中心有多少个标准差",而不是"在坐标纸上量出来多少厘米"。

这篇内容会从公式拆解一路写到可复现的代码,包含一组 4 个二维样本的手算全过程、可以直接抄的 NumPy / SciPy / scikit-learn 实现、阈值怎么定、以及我在实际项目里踩过的坑。适合已经会用 NumPy 但对马氏距离始终没吃透的读者,也适合被"协方差矩阵求逆失败"报错卡住、想找一条稳妥路线的人。基础概念我会先补齐,不假设你熟读线性代数。

1.1 三个让欧氏距离失真的典型场景

先看三个我在实际工作中反复见到的失真场景,理解了这三个,马氏距离到底"修正了什么"就非常直观了。

场景一:量纲不一致。一组数据里,一个特征的单位是毫米(数值范围 0 到 1000),另一个特征的单位是温度(数值范围 20 到 35)。欧氏距离里毫米那一维的差异会彻底压过温度那一维,实际上等于说"温度这个特征我们不看了"。你可以先做标准化把两边拉到同一量级,这确实能救一部分,但标准化只是给每个维度单独除以自己的标准差,它完全无视维度之间的相关性。

场景二:特征高度相关。假设一个二维特征,分别是"身高"和"腿长",两者相关系数 0.95。这时候数据点云是一根又细又长的斜条。一个点沿着长条方向偏出去很远,其实很正常;但它如果是垂直于长条方向偏出去一点点,那就非常异常了。欧氏距离会把这两种偏离一视同仁,因为它们到中心的直线长度可能一样。

场景三:多维下的"距离集中"。维度一高,任意两点之间的欧氏距离会趋于接近,区分度急剧下降。这不是玄学,是维度灾难的直接体现。马氏距离通过白化变换(把协方差结构除掉)能在一定程度上缓解这种塌缩,让"相对位置"重新变得可分辨。

提示:如果你只做了 Z-score 标准化就上欧氏距离,本质上是在假设"标准化之后的协方差矩阵是单位阵"。这个假设在特征相关性强的时候错得很离谱,属于隐性 bug,不容易被发现。

1.2 马氏距离的核心思想:用数据自己的形状当尺子

换个说法。欧氏距离是拿一把固定刻度的直尺去量;马氏距离是拿一把会随数据分布自动变形的软尺去量,在数据摊得开的方衡量得松,在数据挤得紧的方向量得严。

数学上,这把软尺就是协方差矩阵的逆 $\Sigma^{-1}$。协方差矩阵描述的是数据在各个方向上的伸展程度和倾斜方向,它的逆则相当于"把这些伸展和倾斜再除回去"。当你把数据乘上 $\Sigma^{-1}$,原本那个斜着的椭圆点云会被拉成一个正圆(在标准化意义下),这时候再量欧氏距离,就等于在原空间里量马氏距离。

这个过程在文献里叫白化(whitening),是很重要的一层理解:马氏距离 = 白化之后的欧氏距离。这个等价关系一旦建立,后面所有关于"S 奇异怎么办""为什么要 Cholesky"的疑问都会顺理成章。

顺带把常见距离度量的定位理一遍,方便你选型时对照:

度量是否考虑量纲是否考虑相关性计算成本典型用途
欧氏距离否否极低同量纲、弱相关的连续特征
曼哈顿距离否否极低高维稀疏、对异常值更鲁棒
标准化欧氏距离是(单维)否低快速基线
马氏距离是(含相关)是中(需协方差逆)异常检测、判别分析、匹配
余弦相似度否否极低方向性比较、文本向量

2. 公式逐项拆开:每一步到底在算什么

马氏距离的公式短,但每一项的位置决定了它为什么长这样。这一节把公式拆到不能再拆,顺带把求逆之前必须做的几项体检列清楚,这几项能省掉你后面 80% 的报错。

2.1 标准形式与两种等价写法

点 $x$ 到分布(均值 $\mu$,协方差 $\Sigma$)的马氏距离定义是:

$$ D_M(x) = \sqrt{(x-\mu)^{T}\Sigma^{-1}(x-\mu)} $$

公式里三个部分各司其职:

  • $(x-\mu)$ 是中心化,把原点挪到分布中心,这一步和标准化是一样的思路,只是不做缩放。
  • $\Sigma^{-1}$ 是"去相关 + 去尺度",它同时处理了维度之间的相关性和各维度自身的方差。
  • 整个二次型 $(x-\mu)^{T}\Sigma^{-1}(x-\mu)$ 是一个标量,开方之后才是距离,不开方的那个值习惯上记作 $D^2$,在异常检测里我们直接用 $D^2$ 跟阈值比,省一次开方。

两种等价的写法值得记住,因为工程实现里经常用第二种:

写法一(二次型展开):先算出 $v = \Sigma^{-1}(x-\mu)$,再算 $(x-\mu)^T v$。这需要显式求逆。

写法二(Cholesky 白化):对 $\Sigma$ 做 Cholesky 分解得到下三角矩阵 $L$,满足 $\Sigma = LL^{T}$。于是 $\Sigma^{-1} = L^{-T}L^{-1}$,代入后:

$$ (x-\mu)^{T}\Sigma^{-1}(x-\mu) = |L^{-1}(x-\mu)|_2^2 $$

也就是说,只要解一个三角方程 $Lz = x-\mu$,然后算 $z$ 的平方和就完事了。这条路径完全不碰矩阵求逆,数值稳定性好得多,在协方差矩阵接近奇异的时候尤其明显。

还有一个边界情况必须知道:当 $\Sigma$ 是单位阵时,马氏距离退化为欧氏距离。这也解释了为什么"标准化 + 欧氏距离"可以看成马氏距离的一个粗糙近似——它假设 $\Sigma$ 是对角阵。

2.2 求逆之前的四项体检

协方差矩阵求逆是个很容易翻车的操作。下面四项检查建议做成固定流程,每次算之前过一遍,比事后 debug 便宜得多。

第一项:样本量够不够。$d$ 维特征,样本协方差矩阵是 $d \times d$,秩最多是 $n-1$。也就是说 $n \le d$ 的时候协方差矩阵必然奇异,求逆直接失败。工程上的经验线是 $n$ 至少是 $d$ 的 10 倍以上,低于这个数量级求出来的逆矩阵噪声极大,距离值基本不可信。

第二项:有没有完全共线的维度。从十个特征里派生出来的第十一个特征(比如三项之和),会让协方差矩阵的秩少一。这种问题在特征工程做得"太充分"的项目里特别常见。检查方法很简单,看协方差矩阵的条件数:np.linalg.cond(S),如果超过 $10^{12}$,基本可以判定为数值奇异。

第三项:量纲是否悬殊。严格来说马氏距离对量纲是免疫的,因为 $\Sigma$ 里已经包含了各维方差。但数值上它是脆弱的:如果一个维度方差是 $10^{-6}$,另一个是 $10^{6}$,矩阵的条件数会瞬间爆炸,求出来的逆矩阵精度惨不忍睹。所以即便理论上不需要,实操中我还是建议先把特征做一次粗标准化,让各维量级大致相当,纯粹为了数值稳定。

第四项:协方差用 $n$ 还是 $n-1$ 除。样本协方差矩阵通常用 $n-1$(无偏估计)。这个选择在小样本下对结果影响不小,而且会影响后面卡方阈值是否准确。团队里一定要统一,别一个人用np.cov(默认 $n-1$),另一个人手写除以 $n$,最后两个人算出来的距离对不上还以为代码错了。

注意:np.cov默认rowvar=True,也就是每一行是一个变量、每一列是一个观测。这和大多数机器学习数据矩阵(每行一个样本)正好相反,是最经典的坑之一。要么设rowvar=False,要么传X.T,别省略。

2.3 奇异与近奇异的三条出路

条件数已经爆了怎么办?三条路,按推荐顺序排列。

首选:收缩估计(shrinkage)。把样本协方差往一个良态矩阵上拉一点:$\hat{\Sigma} = (1-\alpha)S + \alpha \mu I$,其中 $\mu$ 是 $S$ 的迹除以维度,$\alpha$ 是收缩系数。scikit-learn 里的LedoitWolf和OAS都能自动选 $\alpha$,不用手调。这条路保留了数据的相关结构,同时保证矩阵正定,是我在高维小样本场景下的默认选择。

次选:伪逆(pseudoinverse)。np.linalg.pinv(S)基于 SVD,会丢弃接近零的奇异值方向。效果相当于"只在数据真正张开的那些方向上计算距离",在维度冗余的情况下很合理。风险是它不报错也不提示,你可能在完全错误的空间里算距离而不自知。

兜底:降维。先 PCA 降到 $n$ 的一半以下,再算马氏距离。这会丢掉一部分信息,但换来的是完全可控的数值行为,在对可解释性要求不高的匹配任务里很实用。

3. 手算一遍:4 个二维样本的完整流程

光看公式容易产生"我懂了"的错觉。这里用一组只有 4 个样本的小数据,把五个步骤完整走一遍,数字全部手算,你可以拿计算器对一遍。

数据集是二维的,四条记录:

$$ P_1=(2,\ 1),\quad P_2=(3,\ 5),\quad P_3=(5,\ 4),\quad P_4=(7,\ 9) $$

3.1 第一步:算均值并中心化

两个维度的均值分别是:

$$ \bar{x} = \frac{2+3+5+7}{4} = 4.25,\qquad \bar{y} = \frac{1+5+4+9}{4} = 4.75 $$

所以 $\mu = (4.25,\ 4.75)$。中心化之后的四个点(记作 $d_i = P_i - \mu$):

  • $d_1 = (-2.25,\ -3.75)$
  • $d_2 = (-1.25,\ 0.25)$
  • $d_3 = (0.75,\ -0.75)$
  • $d_4 = (2.75,\ 4.25)$

顺手做个验算:四个 $d_i$ 的分量和应该是零向量。$x$ 方向:$-2.25-1.25+0.75+2.75 = 0$;$y$ 方向:$-3.75+0.25-0.75+4.25 = 0$。都对上了,说明中心化没算错。这一步的验算看着无聊,但当你有几十维特征时,这一个检查能帮你抓到"某列均值算错"或者"漏掉一行数据"这类问题。

3.2 第二步:算协方差矩阵

样本协方差矩阵除以 $n-1 = 3$。三个分量先算:

$$ \sum d_x^2 = 5.0625 + 1.5625 + 0.5625 + 7.5625 = 14.75 $$

$$ \sum d_y^2 = 14.0625 + 0.0625 + 0.5625 + 18.0625 = 32.75 $$

$$ \sum d_x d_y = 8.4375 - 0.3125 - 0.5625 + 11.6875 = 19.25 $$

协方差矩阵:

$$ S = \frac{1}{3}\begin{pmatrix} 14.75 & 19.25 \ 19.25 & 32.75 \end{pmatrix} = \begin{pmatrix} 4.9167 & 6.4167 \ 6.4167 & 10.9167 \end{pmatrix} $$

这里协方差项 6.4167 明显是正的,说明两个维度正相关,点云沿左下到右上的方向拉伸。这正是欧氏距离会误判的方向。

3.3 第三步:求逆矩阵

二阶矩阵求逆有闭式解:$S^{-1} = \frac{1}{\det S}\begin{pmatrix} d & -b \ -b & a \end{pmatrix}$。

行列式:$4.9167 \times 10.9167 - 6.4167^2 = 53.6736 - 41.1736 = 12.5$。

于是:

$$ S^{-1} = \frac{1}{12.5}\begin{pmatrix} 10.9167 & -6.4167 \ -6.4167 & 4.9167 \end{pmatrix} = \begin{pmatrix} 0.8733 & -0.5133 \ -0.5133 & 0.3933 \end{pmatrix} $$

关键验证:逆矩阵的反对角项是负的。这不是巧合,也不是算错了。原协方差矩阵正相关(正的非对角项),它的逆必然呈现负的非对角项,这个负号正是"去相关"的数学体现——沿着一个维度偏离时,另一个维度需要反向补偿才能落在分布的等高线上。如果哪天你算出来的逆矩阵符号跟原矩阵一致,那一定是算错了。

再验算一下 $S \cdot S^{-1}$ 是否接近单位阵:第一行第一列 $4.9167 \times 0.8733 + 6.4167 \times (-0.5133) \approx 4.2917 - 3.2937 = 0.998$,第一行第二列 $4.9167 \times (-0.5133) + 6.4167 \times 0.3933 \approx -2.5240 + 2.5240 = 0$。验证通过。

3.4 第四步:算二次型并开方

对每个点算 $D^2 = d^{T}S^{-1}d$。

$P_1$:$d_1 = (-2.25,\ -3.75)$,先算 $S^{-1}d_1$:

  • 第一分量:$0.8733 \times (-2.25) + (-0.5133) \times (-3.75) = -1.965 + 1.925 = -0.04$
  • 第二分量:$(-0.5133) \times (-2.25) + 0.3933 \times (-3.75) = 1.155 - 1.475 = -0.32$

$D^2 = (-2.25)(-0.04) + (-3.75)(-0.32) = 0.09 + 1.20 = 1.29$,$D_M \approx 1.136$。

$P_2$:$d_2 = (-1.25,\ 0.25)$:

  • 第一分量:$0.8733 \times (-1.25) + (-0.5133) \times 0.25 = -1.0917 - 0.1283 = -1.22$
  • 第二分量:$(-0.5133) \times (-1.25) + 0.3933 \times 0.25 = 0.6417 + 0.0983 = 0.74$

$D^2 = (-1.25)(-1.22) + 0.25 \times 0.74 = 1.525 + 0.185 = 1.71$,$D_M \approx 1.308$。

$P_3$:$d_3 = (0.75,\ -0.75)$:

  • 第一分量:$0.8733 \times 0.75 + (-0.5133) \times (-0.75) = 0.655 + 0.385 = 1.04$(精确值 $0.655 - 0.385$ 的符号要小心,这里第二项是正贡献,取 0.385)

修正一下:$(-0.5133) \times (-0.75) = +0.3850$,所以第一分量 $= 0.6550 + 0.3850 = 1.040$ 有误,实际应为 $0.8733 \times 0.75 = 0.6550$,第二项 $+0.3850$,合计 $1.0400$——等等,这里需要重新核对符号:$d_3$ 第二分量是 $-0.75$,乘以 $-0.5133$ 得 $+0.385$。所以第一分量确实是 $1.040$。第二分量:$(-0.5133) \times 0.75 + 0.3933 \times (-0.75) = -0.3850 - 0.2950 = -0.680$。

$D^2 = 0.75 \times 1.040 + (-0.75)(-0.680) = 0.780 + 0.510 = 1.290$。

嗯,$P_3$ 算出来的 $1.29$ 和 $P_1$ 撞了,说明上面第一分量那步有问题。按正确顺序:$S^{-1}$ 的第一行是 $(0.8733,\ -0.5133)$,点 $(0.75, -0.75)$ 代入得 $0.8733 \times 0.75 + (-0.5133) \times (-0.75) = 0.6550 + 0.3850 = 1.0400$。第二行是 $(-0.5133,\ 0.3933)$,代入得 $(-0.5133) \times 0.75 + 0.3933 \times (-0.75) = -0.3850 - 0.2950 = -0.6800$。然后 $D^2 = 0.75 \times 1.04 + (-0.75) \times (-0.68) = 0.78 + 0.51 = 1.29$。

这确实等于 $1.29$。这不是算错。原因是这四个点相对于均值近似中心对称($d_1 + d_4 = (0.5, 0.5)$,$d_2 + d_3 = (-0.5, -0.5)$),在这种高度对称的小样本上出现距离重合是正常的。这也提示一件事:样本量太小的时候,马氏距离的区分能力本身就不稳定,4 个点算出来的结果只能用来验证流程,不能用来做业务判断。

$P_4$:$d_4 = (2.75,\ 4.25)$:

  • 第一分量:$0.8733 \times 2.75 + (-0.5133) \times 4.25 = 2.4017 - 2.1817 = 0.2200$
  • 第二分量:$(-0.5133) \times 2.75 + 0.3933 \times 4.25 = -1.4117 + 1.6717 = 0.2600$

$D^2 = 2.75 \times 0.22 + 4.25 \times 0.26 = 0.605 + 1.105 = 1.71$,$D_M \approx 1.308$。

3.5 第五步:结果对照与三个交叉验证手段

汇总一下四个点的结果:

样本坐标欧氏距离马氏距离 $D_M$$D^2$
$P_1$(2, 1)4.3741.1361.29
$P_2$(3, 5)1.2741.3081.71
$P_3$(5, 4)1.0311.1361.29
$P_4$(7, 9)5.0211.3081.71

这张表最有意思的地方在于排序完全变了。按欧氏距离,$P_1$ 是离群最远的点;但按马氏距离,$P_1$ 和 $P_2$、$P_3$ 处在同一档,因为 $P_1$ 虽然绝对位置偏,但它偏移的方向恰好和数据拉伸的方向一致,属于"分布本身允许的范围"。

三个交叉验证手段,做小样本计算时建议每次都过一遍:

  1. 中心化残差和为零。所有 $d_i$ 加起来的向量必须是零向量,这条前面已经验过。
  2. 单位阵退化检查。把 $S$ 换成单位阵再算一次,结果应该完全等于欧氏距离。如果不等,说明二次型的实现有转置错误。
  3. 白化后验算。拿 $L^{-1}d_i$ 算平方和,应该和 $d_i^TS^{-1}d_i$ 逐位相等。两条路径对不上就一定有 bug。

4. 代码实现:从手写 NumPy 到调用现成库

手算走通了,落到代码上其实就几行。但不同库的参数约定差异很大,这一节把三条路线都摆出来,顺手把参数坑标清楚。

4.1 NumPy 分步实现,附中间量打印

import numpy as np X = np.array([[2.0, 1.0], [3.0, 5.0], [5.0, 4.0], [7.0, 9.0]]) mu = X.mean(axis=0) # [4.25, 4.75] Xc = X - mu n = X.shape[0] S = Xc.T @ Xc / (n - 1) # 样本协方差, 除 n-1 S_inv = np.linalg.inv(S) def mahalanobis_sq(x, mu, S_inv): d = np.asarray(x, dtype=float) - mu return float(d @ S_inv @ d) for i, row in enumerate(X): d2 = mahalanobis_sq(row, mu, S_inv) print(f"P{i+1}: D2={d2:.4f}, D={np.sqrt(d2):.4f}")

输出应该是 $D^2$ 分别约为 1.29、1.71、1.29、1.71,和手算对得上。如果对不上,八成是Xc.T @ Xc的转置方向写错了——检查方法是打印S看它是否对称,不对称就肯定写反了。

这段代码里有两个细节值得停一下。第一,S = Xc.T @ Xc / (n - 1)用的是矩阵乘法而不是np.cov,好处是转置方向一目了然,坏处是失去了np.cov的各种参数。我倾向于在需要明确控制的场合手写,在快速探索阶段用np.cov(X, rowvar=False)。第二,d @ S_inv @ d的运算顺序是从左到右的矩阵乘,Python 会先算d @ S_inv得到一个向量,再和d做内积,结果是一个标量。写成d @ (S_inv @ d)也等价,而且更省一次中间分配,数据量大时能省一点内存。

4.2 SciPy 的 mahalanobis 与它的参数暗礁

from scipy.spatial.distance import mahalanobis # 注意第三个参数是协方差的逆矩阵, 不是协方差矩阵 d = mahalanobis(X[0], mu, S_inv) print(d) # 1.1358

scipy.spatial.distance.mahalanobis(u, v, VI)的第三个参数VI是inverse covariance,传成 $S$ 本身不会报错,会静默返回一个错误的结果。这是我在代码评审里见过最多次的 bug 之一,而且它不报错、不警告,只是数字悄悄错了。防御办法很简单:在调用前断言矩阵条件数正常,或者用单位阵测一下——mahalanobis(u, v, np.eye(d))必须等于欧氏距离,这个断言加上去,参数传错立刻暴露。

还有一个版本差异要注意:较新的 SciPy 里mahalanobis对u、v只能是一维的,想批量算必须循环或者用pdist/cdist。

4.3 批量计算的三种写法与性能对比

单个点算距离基本不构成性能瓶颈,真实场景里往往是"一批点对一个分布"或者"两批点互相之间"。

写法一:循环调用。最直白,[mahalanobis(x, mu, S_inv) for x in X]。数据量在一万以内完全够用,可读性最好。

写法二:矩阵化。利用 $(X-\mu)S^{-1}(X-\mu)^T$ 的对角线:

Xc = X - mu D2 = np.einsum('ij,jk,ik->i', Xc, S_inv, Xc) D = np.sqrt(np.maximum(D2, 0))

np.einsum这行等价于对每个样本 $i$ 算 $X_c[i] \cdot S^{-1} \cdot X_c[i]$,一次算完,没有 Python 循环。注意np.maximum(D2, 0)是必要的防御——浮点误差可能让本该为 0 的 $D^2$ 变成 $-10^{-16}$,直接开方会得到nan。

写法三:调用 pairwise。

from sklearn.metrics import pairwise_distances DM = pairwise_distances(X, metric='mahalanobis', V=S_inv)

这里V同样要求是逆协方差矩阵。pairwise_distances返回的是 $n \times n$ 矩阵,对角线是 0,正好可以用作聚类或者近邻检索的输入。

三种写法的性能差异在 $n$ 小于一千时基本感知不到,$n$ 到十万级时矩阵化比循环快一个数量级以上。我的建议是先用循环把逻辑跑通、把结果和手算对上,再换矩阵化,别一上来就追求向量化——向量化写法的 bug 极难排查。

4.4 数值稳定性:Cholesky 与伪逆该怎么选

np.linalg.inv在协方差矩阵条件数大的时候会给出带着明显误差的逆矩阵。稳妥做法是用 Cholesky:

import numpy as np from scipy.linalg import solve_triangular L = np.linalg.cholesky(S) # S = L @ L.T, L 为下三角 Xc = X - mu Z = solve_triangular(L, Xc.T, lower=True) # 解 L @ Z = Xc.T D = np.sqrt(np.sum(Z ** 2, axis=0))

这段代码算出来的D和前面np.sqrt(D2)应该逐位一致(在浮点精度内)。它没有求逆,只做了一次三角分解和一次前代,误差传播比求逆小得多。np.linalg.cholesky还有个额外好处:矩阵不正定时它会直接抛LinAlgError,等于免费送你一个正定性检查。而np.linalg.inv对着一个奇异矩阵会返回一堆inf和nan,或者给你一个看起来正常但完全错误的数字,非常危险。

选型上的判断很直接:

情况推荐做法理由
$n \gg d$,条件数正常np.linalg.inv简单,性能好
条件数 $10^{6}$ 到 $10^{12}$Cholesky不加倍放大误差,自带正定检查
条件数大于 $10^{12}$ 或已知奇异收缩估计 或pinv强行求逆结果不可信
批量且重复调用预分解一次,复用 $L$分解成本摊薄

5. 工程落地:阈值、降维与场景适配

距离算出来只是个数字,能不能用好取决于阈值怎么定、嵌到什么流程里。

5.1 异常检测:用卡方分布定一个有依据的门槛

如果数据近似多元正态,那么 $D^2$ 服从自由度为 $d$ 的卡方分布。这条性质让阈值有了统计意义,而不是拍脑袋定一个"3 倍标准差"。

from scipy.stats import chi2 d = 2 thr_95 = chi2.ppf(0.95, df=d) # 5.991 thr_99 = chi2.ppf(0.99, df=d) # 9.210

二维时 95% 的分位点是 5.991,三维是 7.815,十维是 18.307。也就是说,在十维数据里,一个 $D^2 = 18$ 的点只比 5% 的点更远,听上去数值很大,其实一点都不异常。

拿前面手算的结果对照:所有 $D^2$ 都在 1.7 以下,远低于 5.991,四个点全部"正常"。这个结论在小样本上没什么说服力(4 个点算协方差本身就不靠谱),但流程是通的。

几个实操注意点:

  • 用卡方阈值的前提是用真实总体的协方差。当你用样本协方差估计时,$D^2$ 的分布会偏离卡方,$n$ 越小偏离越大,实际离群点会被低估。样本量在几百以上时偏差可以忽略,小样本要谨慎。
  • 维度越高,卡方阈值增长越慢,而 $D^2$ 的期望正好等于维度,所以高维场景下"阈值/维度"的比例其实在缩小。别拿二维的经验往高维套。
  • 阈值一定要在业务侧校准。统计上 5% 的异常率,落到具体业务里可能是每天 500 条告警,运维根本处理不过来。我的做法是先用卡方给一个初始值,再根据告警量和人工复核的准确率调整,最后固化成配置项。

5.2 分类与检索:马氏距离怎么嵌进现有流程

马氏距离在分类里的经典用法是马氏距离判别:对每个类别单独估计均值和协方差,然后看样本到哪个类别的马氏距离最小,就判给哪一类。这在协方差差异明显、样本量不太大的多分类问题上表现相当不错,而且训练成本几乎为零。

需要注意的是,这个方法要求每个类别内至少有足够样本估计协方差。如果某个类别只有三五个样本,协方差根本估不出来,这时候要么用全局协方差(等价于线性判别分析 LDA),要么直接退回欧氏距离加正则化。

在检索场景里,马氏距离常被用作加权余弦的替代。你可以把 $\Sigma^{-1}$ 分解成 $W^TW$(比如 Cholesky 或特征分解),然后对每个向量做变换 $x' = Wx$,之后就能用现成的向量检索库去算欧氏距离了。这个技巧很实用,因为大部分近邻检索索引只支持欧氏或内积,不支持任意度量——先变换再建索引,就把马氏距离"翻译"成了索引能懂的语言。

5.3 高维小样本:收缩估计是最省心的解法

特征数超过样本数是常态,尤其是生物、金融、工业质检这些领域。这时候样本协方差矩阵一定奇异,前面三条出路里,我最常用的是 scikit-learn 的收缩估计:

from sklearn.covariance import LedoitWolf, OAS, MinCovDet lw = LedoitWolf().fit(X) S_shrunk = lw.covariance_ S_inv = np.linalg.inv(S_shrunk)

LedoitWolf会根据数据自动算出最优的收缩强度,把样本协方差往"迹/维度"倍的单位阵方向拉。样本越少、维度越高,收缩越重,极端情况下直接退化成"各向同性"的欧氏距离——这其实是个合理的安全兜底。

MinCovDet是另一条路,它做的是稳健估计,专门对付数据里本身混着离群点的情况。用普通协方差时,几个极端值就能把整个协方差矩阵的形状带偏,而MinCovDet找的是"覆盖大部分数据、体积最小"的那个椭圆。如果你的数据没有预先清洗过,我更推荐这个。代价是计算慢,而且在小样本下可能不收敛,需要设随机种子。

还有一件事常被忽略:马氏距离对特征的选择极其敏感。加进来一个纯噪声维度,它会稀释掉真正的信号,因为协方差矩阵的维度涨了,逆矩阵在每个方向上的"刚度"都被摊薄。所以在计算之前做一轮特征筛选不是可选项,而是必要步骤。我一般的做法是先算一遍相关性,把相关系数超过 0.95 的维度合并,再看条件数,超过阈值就继续删。

6. 常见问题排查速查表

下面这些是我和别人协作过程中真实遇到过的,按报错和"不报错但结果不对"两类分开列。

6.1 报错与崩溃类问题

现象大概率原因处理方式
LinAlgError: Singular matrix样本量小于等于维度,或存在完全共线的特征上收缩估计、删冗余特征,或改用pinv
LinAlgError: Matrix is not positive definite协方差矩阵因浮点误差失去了正定性加1e-8 * I的微小正则,或改用LedoitWolf
结果为nan或inf逆矩阵里有极大值,多半是条件数爆炸检查各维方差是否悬殊,先标准化再算
开方后得到nan$D^2$ 因浮点误差变成微小负数开方前np.maximum(D2, 0)
ValueError: shapes not aligned转置方向写错,或者均值维度不匹配打印所有矩阵的shape,逐个核对

6.2 不报错但结果反直觉的问题

症状一:所有点的距离都差不多。高维场景下的典型表现。原因有两个可能:一是样本量远大于维度,数据在中心附近近似球对称,马氏距离自然收敛到欧氏距离;二是协方差矩阵被收缩得太重,接近单位阵。排查方法是打印协方差矩阵的特征值分布,如果最大最小之比接近 1,那就是数据本身没有方向性,不是代码问题。

症状二:距离值大得离谱,超过 100。除了离群点本身确实极端,还要检查是不是漏了标准化导致某个维度方差极小——方差 $10^{-8}$ 的维度会让逆矩阵里出现 $10^{8}$ 量级的元素,距离平方直接上天。看一眼np.diag(S)就能确认。

症状三:训练集上距离都很小,测试集上集体爆炸。典型的分布偏移。用训练集估出来的 $\mu$ 和 $\Sigma$ 拿去测新数据,如果新数据的均值整体漂移了,所有点的马氏距离会同时变大。这不是算法问题,是监控指标该报警的信号。实际项目里我会把"测试集 $D^2$ 中位数 / 训练集 $D^2$ 中位数"作为一个漂移指标持续跟踪,比值超过 2 就该查数据管道了。

症状四:和同事算出来的结果对不上。九成是协方差的除数不同($n$ 与 $n-1$),或者是np.cov的rowvar用反了。还有一个隐蔽情况:两个人用的特征顺序不一样,虽然数学上结果应该相同,但如果代码里某处手写了下标,顺序一变就错位。定一个特征顺序的常量表,比口头约定可靠得多。

症状五:明明样本量很大,条件数还是爆了。检查有没有把 ID 类字段、时间戳这种高基数特征混进去了。一个递增的时间戳,方差极大且和其他维度几乎无关,会主导整个协方差矩阵。这类字段必须提前剔除,或者做差分、归一化处理。

提示:排查马氏距离相关问题时,最有效的一招是退化验证——把协方差矩阵强制替换成单位阵,看结果是否严格等于欧氏距离。这一步能同时排除公式实现、转置方向、矩阵维度三类错误,五分钟就能定位九成问题。

我在实际项目里的体会是,马氏距离最大的价值不在于它比欧氏距离"更准",而在于它把"数据长什么样"这件事显式地写进了距离定义里。你一旦开始用协方差矩阵思考距离,很多原本靠试错调参的问题会突然变得可解释——为什么这个特征加了没效果、为什么那个维度一直在制造误报,看一眼协方差矩阵的特征值和特征向量就清楚了。反过来,如果数据本身维度低、相关性弱、样本充足,老老实实用标准化欧氏距离就好,别为了用而用,多出来的那点计算和调试成本换不到等价的收益。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进