1. 先搞明白马氏距离到底在解决什么问题
我第一次在项目里被马氏距离"教育",是做一个工业传感器的异常检测。当时手头有六维特征,协方差矩阵一画出来,几个维度之间的相关性高得离谱——温度升高的时候压力几乎同步上升,流量又跟压力呈负相关。我一开始图省事,直接拿欧氏距离算样本离群程度,结果模型把大量正常样本判成异常,现场误报率高得没法看。后来换成马氏距离,同一批数据,误报直接掉下来一大截,那一刻我才算真正理解它存在的意义。
马氏距离(Mahalanobis Distance),中文也叫马哈拉诺比斯距离,本质上是欧氏距离的一个"加权修正版"。欧氏距离只看两点之间的直线距离,默认所有维度量纲一致、彼此独立、方差相同。可现实数据几乎从不满足这三个条件:身高用厘米、体重用千克,数值差着量级;特征之间高度相关;不同维度的波动幅度也完全不一样。马氏距离做的就是把这些"不公平"因素扣除掉,用一个协方差矩阵把数据"白化",然后在标准化的空间里量距离。
它能干什么?一句话说清楚:衡量一个样本点相对于某个数据分布有多"异常",或者衡量两个分布之间样本的相对远近。适合谁看这篇?我按经验分了三类人——做异常检测和故障诊断的工程师,尤其在多传感器、多维特征场景下干活的;做分类聚类、需要特征加权和相似度度量的算法开发者;还有统计、质量工程、金融风控里要做多元正态检验和离群点剔除的同学。前两类人看重的是它的实操价值,第三类人更在意它的数学性质,这篇我都会讲到。
如果你只想记住一句话,那就记这个:欧氏距离问的是"你离我多远",马氏距离问的是"你离我多远,以及我这一坨数据的形状允不允许你这么远"。这句话听起来糙,但它把协方差矩阵的作用解释得很到位——数据的"形状"就是协方差结构,马氏距离把形状也算进了距离里。
2. 公式拆开看:每一步到底在算什么
2.1 从标量到向量,公式的两种形态
马氏距离有两个常用版本,我建议你两个都记住,因为不同场景下用哪个完全取决于你手上有没有总体信息。
版本一:单个样本点到分布中心的距离
$$ D_M(\mathbf{x}) = \sqrt{(\mathbf{x}-\boldsymbol{\mu})^T \Sigma^{-1} (\mathbf{x}-\boldsymbol{\mu})} $$
其中 $\mathbf{x}$ 是样本向量,$\boldsymbol{\mu}$ 是总体或样本均值向量,$\Sigma$ 是协方差矩阵,$\Sigma^{-1}$ 是它的逆。
版本二:两个样本点之间的距离
$$ d(\mathbf{x},\mathbf{y}) = \sqrt{(\mathbf{x}-\mathbf{y})^T \Sigma^{-1} (\mathbf{x}-\mathbf{y})} $$
实际工程里版本一用得更多,因为我们要判断"这个点相对整体分布算不算异常",本质是点对分布的距离。版本二多用于样本间相似度计算,比如聚类时替换欧氏距离。
我特别想强调一个细节:根号里面那个二次型,本身就是马氏距离的平方,很多代码库直接返回平方值,你如果不注意,后面的阈值就全错。我见过有人拿scipy.spatial.distance.mahalanobis算出来一个值,又拿自己手写的公式算平方值去比对,发现差一大截,排查半天以为库有 bug,其实就是漏开根号。这个坑我在第 4 节还会专门讲。
2.2 把公式拆成三个动作,理解就轻松了
公式看着唬人,但拆成动作其实只有三步:
- 中心化:$\mathbf{x}-\boldsymbol{\mu}$,把数据平移到原点。这一步跟标准化里的减均值是一个意思,目的是消除"数据中心在哪儿"的影响。
- 白化(加权):左乘 $\Sigma^{-1}$,再用 $(\cdot)^T$ 右乘一次,这一步是核心。协方差矩阵的逆相当于给每个方向重新分配权重——方差大的方向权重小,方差小的方向权重大;相关性强的两个维度会被合并,避免重复计分。
- 开根号:把二次型还原成跟原特征同量纲的距离值。
这里最容易让人困惑的是第二步。用生活化的类比解释:你和朋友站在一个倾斜的橄榄球场上量距离,欧氏距离是拿尺子量直线,但球场是扁的、拉长的,横着走一步和竖着走一步的"实际难度"完全不同。$\Sigma^{-1}$ 就是那块场地的"难度地图",它告诉你沿哪个方向走更"贵"。协方差矩阵描述的就是数据云团的形状,是一个被拉长的椭球,$\Sigma^{-1}$ 把它变换回标准球,之后在这个球里量距离,就是公平的。
2.3 为什么用协方差逆而不是协方差本身
这个问题我被问过很多次。直觉上你可能觉得,用协方差矩阵去加权不就行了?为什么要取逆?
原因在于权重的方向是反的。协方差大意味着该方向数据分散、波动大,那么在判断异常时,这个方向大一点是"正常的",应该降低惩罚,也就是权重小。协方差小意味着该方向数据集中、很稳定,一旦偏出去就非常可疑,应该加重惩罚。协方差矩阵的逆正好满足这个"倒数"关系,所以必须用逆。
还有个更严谨的解释:对整个多元正态分布做白化变换$\mathbf{z} = \Sigma^{-1/2}(\mathbf{x}-\boldsymbol{\mu})$,变换后 $\mathbf{z}$ 的协方差矩阵是单位阵,也就是说数据被"揉"成了一个标准差全为 1、各维独立的标准正态球。马氏距离的平方等于 $\mathbf{z}^T\mathbf{z}$,正好是这个球里的欧氏距离平方,而 $|\mathbf{z}|^2$ 服从自由度为 $p$ 的卡方分布($p$ 为特征维数)。这个性质非常关键,它给了我们一个天然阈值:如果数据服从多元正态,马氏距离平方大于 $\chi^2_{p,0.975}$ 的样本,就可以在 5% 显著性水平下判为异常。我在做传感器异常检测时,阈值就是直接查卡方表定的,比拍脑袋定分位数稳得多。
2.4 三个必须提前知道的坑
在往下讲计算步骤之前,有三个前提性问题必须先摆出来,否则你算出来的数会骗你。
第一,协方差矩阵必须可逆。当特征维数 $p$ 大于样本数 $n$,或者特征之间存在完全共线性时,$\Sigma$ 是奇异的,逆根本不存在。这时候要么降维(PCA),要么用伪逆,要么加正则项 $\Sigma + \lambda I$ 让特征值不至于太小。我踩过最典型的坑是一个 40 维特征、只有 25 个样本的项目,numpy.linalg.inv直接抛奇异矩阵异常,改用pinv之后结果能出来,但极不稳定,后来老老实实先做 PCA 降到 10 维。
第二,协方差矩阵对量纲敏感。虽然马氏距离在理论上对线性变换不变(这其实是它比欧氏距离优雅的地方),但实际计算时如果某个特征量纲特别大,协方差矩阵里它的方差项会占绝对主导,导致条件数极差,数值精度崩掉。所以实操中我基本都会先对特征做标准化,或者至少做一次尺度检查。
第三,正态性假设。卡方阈值那套严格依赖多元正态。数据重尾、多峰或者明显非正态时,距离值依然能算,但阈值不能直接查卡方表,得用经验分位数或者稳健协方差估计(MCD 之类)。这一点在金融数据和工业故障数据里尤其重要,异常样本本身会污染均值方差估计,需要专门处理。
3. 手把手计算流程:从原始数据到最终距离
3.1 完整步骤清单与参数说明
我把整个流程拆成六个步骤,这是我在项目里实际执行的顺序,不是教科书顺序。区别在于我把"检查"提前了,避免算完才发现数据有问题。
- 数据准备与预处理:收集样本矩阵 $X$,形状为 $n \times p$($n$ 个样本,$p$ 个特征)。检查缺失值、异常极值、量纲差异。
- 计算均值向量$\boldsymbol{\mu}$:按列求均值,$\boldsymbol{\mu} = \frac{1}{n}\sum_{i=1}^{n}\mathbf{x}_i$。
- 计算协方差矩阵$\Sigma$:注意除以 $n-1$ 还是 $n$ 的问题(下面详说)。
- 求协方差矩阵的逆$\Sigma^{-1}$:并检查条件数,判断数值稳定性。
- 计算目标样本的偏差向量$\mathbf{d} = \mathbf{x}_t - \boldsymbol{\mu}$。
- 代入二次型并开根号,得到马氏距离。
每一步需要注意的参数和细节,我用表格整理一下,这张表是我从多次返工里总结出来的:
| 步骤 | 关键参数/选择 | 实务建议 | 常见错误 |
|---|---|---|---|
| 预处理 | 是否标准化 | 多量纲特征必做 | 标准化后仍用原始数据的均值方差评估 |
| 均值 | 样本均值 vs 稳健均值 | 怀疑异常污染时用中位数或截尾均值 | 用含异常值的均值导致参考中心偏移 |
| 协方差 | $n-1$ 无偏 vs $n$ 有偏 | 样本量小时用 $n-1$ | 混淆两种估计导致距离系统性偏差 |
| 求逆 | 逆 vs 伪逆 vs 正则化 | 先看条件数,大于 $10^5$ 都要警惕 | 直接调inv不检查,得到数值垃圾 |
| 偏差异 | 目标样本定义 | 明确是"相对训练分布"还是"相对全体" | 把待测样本混进训练集一起算均值方差 |
| 开方 | 平方值 vs 距离值 | 阈值一律按平方值 + 卡方表定 | 平方和开方混用导致阈值错得离谱 |
3.2 协方差矩阵的估计细节:$n$ 还是 $n-1$
这是个看似琐碎但实际影响很大的问题。总体协方差的无偏估计用 $n-1$,但如果你把训练数据看作一个确定的有限总体、要算的是这批数据内部的距离,用 $n$ 也说得通。
我的经验是:距离的用途决定选择。如果这套均值方差是用来给新来的未知样本打分的(在线异常检测的典型场景),那训练集只是总体的一次抽样,用 $n-1$ 更合理,因为你想估计的是总体的分布形状。如果只是在这批已知样本内部算两两距离(比如某些聚类索引的内部一致性计算),用 $n$ 也行。
样本量小的场景下这个差别会放大。举个例子,样本量 20、特征维数 10,$n-1$ 与 $n$ 的比值是 0.95,协方差整体放大 5%,距离平方相应放大 5%,开方后 2.5%。如果你的异常判定阈值卡得很紧,这 2.5% 就可能造成边缘样本的判定翻转。我现在的固定做法是:训练分布估计一律用 $n-1$,并且在文档里写清楚,避免合作方用另一套公式复现时对不上数。
还要提醒一个更隐蔽的问题——均值也是一种估计。当你用样本均值代替总体均值时,马氏距离平方其实服从的是一个缩放过的 Hotelling T 分布,而不是纯净的卡方。样本量小时,直接查卡方表会偏保守(把正常样本判异常的比例比预期高)。样本量上百之后近似得不错,可以不用纠结;样本量只有二三十,我建议用经验分位数替代卡方阈值。
3.3 数值稳定性:条件数与正则化的实操判断
协方差矩阵求逆是整个流程里最脆的一环。我的习惯是先看条件数(最大奇异值除以最小奇异值),这个数直接反映了矩阵有多"接近奇异"。
- 条件数小于 $10^2$:健康,放心求逆。
- $10^2$ 到 $10^4$:可以接受,但我会检查一下是不是有冗余特征。
- $10^4$ 到 $10^6$:危险区,求逆结果开始不可信,建议做 PCA 降维或者加正则。
- 大于 $10^6$:基本别想直接求逆了,出来的数会带着巨大的误差。
正则化的写法很简单:$\Sigma_\lambda = \Sigma + \lambda I$,$\lambda$ 取一个相对于协方差对角线平均值的很小比例,比如 $10^{-4}$ 到 $10^{-2}$ 倍。 $\lambda$ 太大会人为压平数据的形状、让距离趋近欧氏距离;太小又起不到稳定作用。我一般从对角线均值的 $10^{-3}$ 倍试起,然后看条件数降到多少,反复几次就能找到合适的档位。
还有个小技巧:用 Cholesky 分解代替直接求逆。因为协方差是正定的,可以分解成 $\Sigma = LL^T$,然后求 $LL^T\mathbf{d}$ 的解即可,不需要显式算出逆矩阵,数值精度更好、速度也更快。在样本量大或者要在循环里反复算距离时,这个优化很值。
3.4 一个完整的手算级示例
为了让你真的能对上数,我构造一个二维的小例子,所有中间结果都给出来。
设训练数据两个特征,样本如下:
$$ X = {(2,2),\ (4,4),\ (6,6),\ (8,8),\ (3,5)} $$
先算均值:$\bar{x}_1 = (2+4+6+8+3)/5 = 4.6$,$\bar{x}_2 = (2+4+6+8+5)/5 = 5.0$。
协方差矩阵(用 $n-1=4$):
- 偏差平方和 $S_{11} = (2-4.6)^2+(4-4.6)^2+(6-4.6)^2+(8-4.6)^2+(3-4.6)^2 = 6.76+0.36+1.96+11.56+2.56 = 23.2$
- $S_{22} = 9.0+1.0+1.0+9.0+0 = 20.0$
- $S_{12} = (-2.6)(-3.0)+(-0.6)(-1.0)+(1.4)(1.0)+(3.4)(3.0)+(-1.6)(0) = 7.8+0.6+1.4+10.2+0 = 20.0$
所以 $\Sigma = \begin{pmatrix} 5.8 & 5.0 \ 5.0 & 5.0 \end{pmatrix}$。
行列式 $= 5.8 \times 5.0 - 5.0 \times 5.0 = 29 - 25 = 4$,逆矩阵为:
$$ \Sigma^{-1} = \frac{1}{4}\begin{pmatrix} 5.0 & -5.0 \ -5.0 & 5.8 \end{pmatrix} = \begin{pmatrix} 1.25 & -1.25 \ -1.25 & 1.45 \end{pmatrix} $$
现在测一个样本点 $(7, 3)$。偏差 $\mathbf{d} = (7-4.6,\ 3-5.0) = (2.4,\ -2.0)$。
二次型 $= 1.25 \times 2.4^2 + 2 \times (-1.25) \times 2.4 \times (-2.0) + 1.45 \times (-2.0)^2$ $= 1.25 \times 5.76 + 6.0 + 1.45 \times 4.0$ $= 7.2 + 6.0 + 5.8 = 19.0$
开根号得马氏距离约4.36。对照一下欧氏距离:$\sqrt{2.4^2 + 2^2} = \sqrt{5.76+4} = 3.12$。马氏距离明显更大,原因是这个数据云团沿着 $y=x$ 方向被拉长,$(7,3)$ 正好落在垂直于云团主轴的方向上,属于"偏离形状"的方向,惩罚自然更重。
反过来测 $(9,9)$,偏差 $(4.4, 4.0)$。二次型 $= 1.25 \times 19.36 + 2 \times 1.25 \times 17.6 + 1.45 \times 16 = 24.2 + 44.0 + 23.2 = 91.4$……等一下,方向要算对,第二项是 $2 \times (-1.25) \times 4.4 \times 4.0 = -44.0$。所以 $= 24.2 - 44.0 + 23.2 = 3.4$,马氏距离约1.84。欧氏距离是 $\sqrt{19.36+16} = 5.95$。
这个对比特别能说明问题:欧氏距离看 $(9,9)$ 比 $(7,3)$ 更远,但马氏距离认为 $(7,3)$ 危险得多,因为 $(9,9)$ 顺着云团延伸的方向,是"符合数据形状"的远,而 $(7,3)$ 是垂直方向的偏,是"违反形状"的远。这就是马氏距离真正的价值所在,也是我在异常检测里必须用它而不是欧氏距离的根本原因。
我把这个例子在代码里跑一遍核对,结果一致。你也可以用这段代码验证别的点:
import numpy as np X = np.array([[2,2],[4,4],[6,6],[8,8],[3,5]], dtype=float) mu = X.mean(axis=0) Sigma = np.cov(X.T, ddof=1) inv_Sigma = np.linalg.inv(Sigma) def maha(x, mu, inv_S): d = np.asarray(x, dtype=float) - mu return float(np.sqrt(d @ inv_S @ d)) print("mean:", mu) print("cov:\n", Sigma) print("inv:\n", inv_Sigma) print("Maha(7,3) =", round(maha([7,3], mu, inv_Sigma), 3)) print("Maha(9,9) =", round(maha([9,9], mu, inv_Sigma), 3))输出应该是4.359和1.844,跟手算吻合。跑之前提醒一句,ddof=1就是对应 $n-1$,默认是 1,但显式写出来更保险,也方便别人读你的代码。
4. 工程落地:常见问题、排查与避坑实录
4.1 四类高频错误与快速定位方法
马氏距离的坑不算多,但一旦踩上,现象往往很迷惑。我把这几年遇到的和被同事问过的整理成一张排查表,思路是"看现象→查原因→动手验证":
| 现象 | 可能原因 | 快速验证方法 | 处理方式 |
|---|---|---|---|
距离全是inf或nan | 协方差奇异,逆阵含极大值 | 看条件数、看矩阵秩 | 降维 / 加正则 / 用伪逆 |
| 距离值普遍偏小、区分度差 | 特征间高度共线,信息被合并 | 算特征值,看是否接近 0 | 去掉冗余特征后重算 |
| 阈值一定,误报率异常高 | 用了卡方阈值但数据非正态 | 画距离平方的直方图看尾部 | 改经验分位数或稳健估计 |
| 与第三方库结果对不上 | 平方值与开方值混用 | 直接比对平方 | 统一口径,绝对值误差检查 |
| 新增样本距离突然爆炸 | 量纲未统一,某维方差极小 | 检查各列标准差 | 标准化后重新训练分布 |
这里第一行那个"全 inf/nan"是最常见的。判断方法很直接:np.linalg.cond(Sigma)一跑就出来。如果返回一个 $10^{12}$ 量级的数,就不用往下折腾了,一定是秩亏或者近奇异。这时候不要急着用pinv糊过去,先问自己一句:这些特征里是不是有几个其实是同一个东西的两种测法?我那次的六维传感器数据里,温度和温度变化率就是强相关的,删掉一个之后条件数从 $10^9$ 掉到 $10^2$,问题自然消失。
4.2 异常样本污染均值方差怎么办
这是做异常检测时最隐蔽的坑。设想你要用马氏距离找异常,但训练数据里本身就混着 5% 的异常点。这些点会把均值和协方差往自己那边拽,导致"正常区域的边界"被撑大,异常点反倒显得不那么异常了。这是典型的掩蔽效应(masking),不处理的话模型会系统性漏报。
我在项目里的常规做法是两轮迭代:第一轮用全部数据算均值和协方差,得到每个样本的马氏距离;剔除距离平方超过卡方 99% 分位数的样本(这一轮阈值放宽,宁滥勿缺);第二轮用剩下的干净数据重新估计均值和协方差,再重算一遍距离。通常两轮就够了,反复迭代容易把真实的边缘正常样本也剔掉。
更严一点可以用稳健协方差估计,比如最小协方差行列式(MCD)方法,它的思想是找一个包含大约一半样本的最小体积椭球,用这批"最核心"的样本估计协方差,对异常点天然免疫。实现上sklearn.covariance.MinCovDet直接可用。代价是计算量大一些,样本量上万时要慎重。我个人经验是:数据量小、异常比例不明、又对精度要求高,上 MCD;数据量大、异常只是零星几个,两轮迭代的性价比更高。
4.3 阈值到底怎么定才靠谱
阈值定得对不对,直接决定这套方法能不能用。我按数据情况和场景给你三条路,你可以对照自己的情况选:
路子一:数据近似多元正态、样本量充足。用卡方阈值,自由度等于特征维数 $p$。常用的分位点我列一下,省得你查表:
| 置信水平 | $p=2$ | $p=5$ | $p=10$ | $p=20$ |
|---|---|---|---|---|
| 90% | 4.605 | 9.236 | 15.987 | 28.412 |
| 95% | 5.991 | 11.070 | 18.307 | 31.410 |
| 99% | 9.210 | 15.086 | 23.209 | 37.566 |
| 99.9% | 13.816 | 20.515 | 29.588 | 45.315 |
注意这张表里是距离平方的阈值,别拿去跟距离值直接比。
路子二:数据分布不明或非正态。用经验分位数。把所有训练样本的距离平方算出来排序,取 95% 或 99% 分位点作为阈值。好处是完全不依赖分布假设,坏处是受异常比例影响——如果异常样本超过 5%,95% 分位点可能就切在异常区里了。我的处理是先用路子三或者两轮迭代把训练集洗干净,再套这条。
路子三:有标注数据。这是最理想的。直接拿验证集上的正常/异常标签,画距离平方的分布,找使 F1 或者业务成本最优的切点。工业场景里我一般按"漏报成本 : 误报成本"来调,比如漏报一台设备故障的代价远高于多检查几次,阈值就往低里压。
还有一个实操层面的提醒:阈值必须和训练分布绑定保存。我见过项目上线后出问题,原因是线上打分用的均值协方差是旧版本,阈值是新版本,对不上。所以我在工程实现里会把均值向量、协方差逆矩阵(或它的 Cholesky 因子)、阈值这三样打包成一个模型对象一起序列化,版本号写进文件名里。
4.4 与欧氏距离、标准化欧氏距离的取舍
既然马氏距离这么好,是不是所有场景都该用它?不是。我自己的判断标准是看协方差矩阵估计得可不可靠。
马氏距离的"加分项"完全依赖协方差矩阵的准确性。特征维数 $p$ 和样本量 $n$ 有个经验关系:粗略地,$n$ 至少要到 $p$ 的 10 倍左右,协方差的估计才比较稳。$n$ 只有 $p$ 的两三倍时,协方差矩阵虽然可逆,但噪声极大,算出来的距离波动很厉害,反而不如老老实实用标准化欧氏距离。
三种距离的适用场景我理一下:
- 欧氏距离:各维量纲一致、相互独立、方差相近。图像像素块比对、纯几何空间里的最近邻,用这个最省事。
- 标准化欧氏距离:量纲不同但维度间基本独立。把每维除以标准差,消除尺度差异。这是"穷人版马氏距离",只对对角线加权,不管相关性。特征独立的场景下它和马氏距离等价。
- 马氏距离:维度间有明显相关性,且样本量够估计协方差。多传感器系统、多元质量控制、需要考虑数据"形状"的相似度计算,非它不可。
我在项目里有个偷懒但有效的判断流程:先算特征两两的相关系数矩阵,如果有超过 0.5 的显著相关对,直接上马氏距离;如果满屏都是 0.1 以下的小相关,省点计算量用标准化欧氏距离即可。这个流程帮我省了不少做无用功的时间。
5. 代码复现:从零实现与库函数对照
5.1 三种实现路径的取舍
实际开发中你至少有三条路,各有各的适用面:
- 纯手写 NumPy:也就是我在 3.4 节给的那段。优点是每一步都透明、便于调试和定制(比如加正则、换稳健估计),缺点是没有批量优化,样本上万时循环会慢。
scipy.spatial.distance.mahalanobis:单点计算方便,但输入是原始协方差矩阵而不是逆矩阵,它内部自己求逆。注意它返回的是距离值(开了根号)。scipy.spatial.distance.cdist配合metric='mahalanobis':批量算两两距离时用它,会利用VI参数传入逆协方差,效率好很多。这一路的坑是文档里参数名容易看错,VI必须是逆协方差矩阵,你传协方差矩阵进去不会报错,但结果全错,而且错得很有迷惑性——距离看起来还算"合理",只是数值不对。
我建议新手先用scipy跑通,有了参照之后再去手写核对,这样能最快建立起对结果的直觉。下面这段代码把三条路并排放在一起,直接输出比对结果:
import numpy as np from scipy.spatial.distance import mahalanobis X = np.array([[2,2],[4,4],[6,6],[8,8],[3,5]], dtype=float) mu = X.mean(axis=0) Sigma = np.cov(X.T, ddof=1) VI = np.linalg.inv(Sigma) x = np.array([7.0, 3.0]) # 路径1:手写二次型 d = x - mu d_manual = np.sqrt(d @ VI @ d) # 路径2:scipy 单点(传协方差矩阵) d_scipy = mahalanobis(x, mu, Sigma) # 路径3:批量(传逆协方差矩阵) from scipy.spatial.distance import cdist d_batch = cdist(x[None, :], X, metric='mahalanobis', VI=VI) print(f"manual = {d_manual:.6f}") print(f"scipy = {d_scipy:.6f}") print(f"batch = {d_batch.ravel()}") # 注意:这里是对 X 中每个点的距离注意第三行输出的是一组值,因为批量模式算的是 $(7,3)$ 到训练集每个点的马氏距离,跟路径 1、2 算的"点到分布中心"不是同一个东西。这是新手最容易混淆的地方:mahalanobis单点调用是"点到中心的距离",而cdist是"点与点之间的距离",两者都叫马氏距离,语义完全不同,务必分清。这也是为什么我一直强调要先把语义定义清楚,再谈公式。
5.2 生产环境里我会额外加的三件事
手写或调库跑通只是第一步,真的放到线上,我还会补三样东西,这三样都是被生产事故教出来的。
第一,分布对象的封装与版本管理。把均值、逆协方差(或者 Cholesky 因子)、阈值、训练样本数、特征名一起包成一个 dataclass 或者字典,序列化存盘,特征名的顺序也要存。我省过这个事,结果线上特征拼装顺序变了,同一个名字的特征落到了不同位置,距离算出来全是错的,排查了大半天。顺序问题不写下来就一定会犯,别赌。
第二,对输入的防护。线上打分函数第一件事就该检查:输入维度对不对、有没有 nan、有没有超出训练范围几个数量级的离群值。马氏距离对极端值非常敏感,因为它是二次型,一个维度偏 100 倍,距离平方会放大到 10000 倍量级,虽然"结果很大"在异常检测里是正常现象,但如果是因为单位传错(比如米和毫米混了)导致的,那就是误报。我现在的做法是记录每个特征的训练时上下界,超出 10 倍标准差的输入直接标记为"输入异常"而不是"样本异常",走另一条告警路径,避免污染主流程。
第三,性能层面的预计算。如果是在循环里给大量样本打分,把 Cholesky 因子算一次存下来,每次打分用两次三角方程求解,会比每次调inv快一到两个数量级。我做过粗略对比,10 万次打分,反复求逆的写法要花几十秒,预分解的写法在一两秒内完成。这个优化不改变任何数学结果,属于纯赚。
5.3 把我踩过的坑浓缩成一份自查清单
我把常见问题按发生阶段排一下,你照着检查一遍基本能避开 90% 的坑:
- 算之前:特征里有没有完全共线的?样本数是不是远小于特征数?各列标准差差了几个数量级?这三个问题任何一个答案是"是",先处理再算。
- 算之中:用的是 $n$ 还是 $n-1$?求逆前看没看条件数?平方值和距离值有没有混用?
- 定阈值时:卡方表里查的是平方阈值还是距离阈值?数据正态性能不能接受?训练集洗过没有?
- 上线之后:均值协方差和阈值是不是同一版本?特征顺序有没有绑定保存?输入防护有没有做?
还有一条是经验性质的:当你发现马氏距离算出来的结果"不合直觉"时,先别怀疑公式,去看特征之间的相关性。我遇到过的所谓"结果异常"里,八成以上是某个特征其实跟别的特征重复了,或者某个特征的量纲跟它应有的标准差严重不匹配。数据本身的问题永远比公式的问题多。
最后分享一个我在实际项目中形成的习惯。每接一个新数据集要做马氏距离,我都会先花十分钟做三张图:特征相关矩阵热力图、每维的分布直方图、样本马氏距离平方的排序曲线。前两张图帮我在算之前就发现共线性和量纲问题,第三张图能直接看出数据里有没有天然的异常簇——如果排序曲线在某个位置出现明显的拐点,那批点基本就是异常,阈值都不用费心去调,拐点位置就是答案。这三张图花的时间,远比后面调参数返工省下来的多。