1. 项目概述与整体设计思路
做模式识别和机器学习的朋友,一定对Fisher准则下的线性分类器设计不陌生。它是监督学习中一个相当经典的线性降维与分类方向,本质是在高维特征空间里找一条最优投影方向,让两类样本的投影尽量分开,同时每一类内部的投影尽量紧凑。很长一段时间里,我处理小样本、低维二分类问题时,首先尝试的就是这个方法。它不需要复杂的深度学习框架,甚至几十行Python就能落地,却能把问题的结构看得非常清楚。这篇文章围绕基于Fisher准则的线性分类器设计展开,从数学原理到代码实现再到踩坑排查,把整个过程讲透。适合刚入门模式识别、机器学习的学生,也适合做传统特征分类、想做基线模型的工程师。读完你至少能亲手实现一个可用的线性判别分类器,并知道每个参数改动背后的意义。
1.1 这个设计到底解决什么问题
Fisher准则下的线性分类器设计,可以同时解决两个问题:一个是分类,一个是降维。分类时,我们计算一个投影向量 (w),把每个样本 (x) 投影成标量 (y = w^T x),然后在 (y) 上找一个阈值,大于阈值判成某一类,小于阈值判成另一类。降维时,我们把原本 (d) 维的特征压缩到一维,而且要求这一维最大化类别之间的可分性。很多工程场景里,二维散点图上看起来混杂的数据,做完Fisher投影之后,往往能一眼看出两类是否还有救。
设计过程本质上是在做一次权衡:希望不同类别投影后的均值离得越远越好,同时每一类的投影散开程度越小越好。Fisher准则把这个直觉量化成了目标函数,用“类间距离除以类内离散度”来评价投影方向的好坏。这个比值越大,说明投影方向越适合用来分类。理解了这一点,后面所有的公式推导和代码实现,其实都是在围绕这个比值做优化。
1.2 为什么不是感知机、不是逻辑回归
刚接触线性分类器时,很多人会问:感知机和逻辑回归也能做线性分类,为什么还要搞Fisher准则?我的看法是,Fisher在处理低维、小样本、线性可分性尚可的数据时,有几个非常难得的优点。感知机用错误驱动更新权重,算法是简单,但最终收敛到的分界面往往受初始值和训练样本顺序影响,解不唯一;逻辑回归能输出概率,但对特征共线性比较敏感,还要调学习率和迭代次数。Fisher准则直接基于样本统计量,把投影方向表达成解析解,不需要学习率,不需要随机梯度下降,也不存在“当前解是否收敛”的问题。
当然,我并没有说Fisher在所有场景下都更好。逻辑回归在概率校准方面有优势,感知机在大规模流式数据上有它的用武之地。但Fisher最大的特点是稳定、快速、可复现。你只需要算每类样本的均值向量和类内散度矩阵,就能得到唯一的方向。对于需要上线做基线的项目来说,这种确定性非常宝贵。很多传统系统里,Fisher判别分析也被当作首选基线,先跑一版,看看特征空间到底有没有可分性。
1.3 适合的应用场景
根据我的经验,凡是满足“两类样本分布近似椭球、协方差差距不那么夸张、类别均值有明显差异”的二分类问题,都适合先跑一版Fisher分类器。典型场景包括传感器状态判断、产品质量检测中的正负样本判定、语音端点检测前段特征判别、医学指标初筛等。它也很适合作为特征选择工具,比如有上百维特征,先用Fisher投影到一维,观察投影分布,如果两类样本在投影后重叠严重,说明现有特征可能不够用,这时候再考虑上复杂模型也不迟。
对于多分类问题,Fisher可以按一对多或者逐一配对的方式扩展,也可以直接使用多分类线性判别分析变体。不过多分类时要注意类别不平衡和类别数量增加带来的阈值复杂化。这些我会在后面专门展开。总体而言,Fisher在简单场景下可以当主角,在复杂场景下可以当哨兵,先帮你判断特征是否有效。
2. Fisher准则核心原理与数学推导
2.1 从两类均值和离散度开始
假设有两类样本,第 (k) 类的样本集合是 ({x_i^{(k)}}),其中 (k=1,2),每类样本数量分别是 (n_1) 和 (n_2)。首先计算每类的均值向量:
[ m_k = \frac{1}{n_k}\sum_{i \in 第k类} x_i^{(k)} ]
这是类别中心的估计。接下来定义类内散度矩阵 (S_w),它由两类各自的散度矩阵相加得到:
[ S_k = \sum_{i \in 第k类} (x_i^{(k)} - m_k)(x_i^{(k)} - m_k)^T ]
[ S_w = S_1 + S_2 ]
类内散度矩阵刻画的是每个类别内部样本离自己类别中心的整体远近。如果说每类数据像一团云,(S_w) 就是把两团云的“厚度”加起来。另外还要定义类间散度矩阵:
[ S_b = (m_1 - m_2)(m_1 - m_2)^T ]
它只和两类均值向量之差有关,和样本具体散布无关。Fisher准则的目标,就是找到一个投影方向 (w),让投影后的 (w^T S_b w) 尽量大,同时让投影后的 (w^T S_w w) 尽量小。
2.2 目标函数:广义瑞利商
把样本投到方向 (w) 上之后,第一类投影均值是 (w^T m_1),第二类投影均值是 (w^T m_2)。类间距离的平方可以写成:
[ (w^T m_1 - w^T m_2)^2 = w^T (m_1 - m_2)(m_1 - m_2)^T w = w^T S_b w ]
而投影后类内离散度的总和就是:
[ \sum_{i \in 第1类} (w^T x_i^{(1)} - w^T m_1)^2 + \sum_{i \in 第2类} (w^T x_i^{(2)} - w^T m_2)^2 = w^T S_w w ]
于是Fisher准则的目标函数就是一个广义瑞利商:
[ J(w) = \frac{w^T S_b w}{w^T S_w w} ]
这个形式和信噪比很像,分子是信号,分母是噪声。我们可以想象用手电筒从某个角度照射两团点云,好的照射角度让两团点云在墙面上的影子拉得很开,同时每团影子内部又是紧凑的。Fisher准则选的就是这个角度。
2.3 求Fisher判别向量
因为 (J(w)) 对 (w) 的尺度不敏感,把 (w) 放大任何倍数,分子分母都会被同时放大,比值不变。所以我们可以人为加上一个约束,令:
[ w^T S_w w = 1 ]
然后最大化 (w^T S_b w)。使用拉格朗日乘子法:
[ L = w^T S_b w - \lambda (w^T S_w w - 1) ]
对 (w) 求导并令梯度为零,得到:
[ S_b w = \lambda S_w w ]
这是一个广义特征值问题。仔细观察会发现,(S_b w) 的方向永远和 ((m_1 - m_2)) 平行,因此不用真的去解特征值分解,也能直接得到最佳投影方向:
[ w \propto S_w^{-1}(m_1 - m_2) ]
这个形式非常简洁。如果 (S_w) 不可逆,或者因为特征维度太高导致数值不稳定,一般会在 (S_w) 上加一个小的单位阵:
[ S_w^{reg} = S_w + \lambda I ]
然后再计算:
[ w = (S_w^{reg})^{-1}(m_1 - m_2) ]
这里 (\lambda) 是正则化参数,具体取值后面会讲。需要特别注意的是,(w) 的方向是最优的,但长度不影响分类结果。所以实际代码里,我一般会把 (w) 归一化,方便画图和比较方向。
投影之后怎么分类呢?最简单的阈值取两类投影均值的中间点:
[ th = \frac{1}{2}(w^T m_1 + w^T m_2) ]
假如两类先验概率明显不同,或者投影后两类方差差别很大,这个阈值就不够用了。更稳妥的做法是在验证集上做阈值搜索,或者假设投影后每类服从高斯分布,再用贝叶斯决策求阈值。这部分我会在第三节和第五节详细说明。
2.4 多分类的推广逻辑
Fisher准则天然适合二分类,但也可以推广到多分类。多分类情况下,定义全局散度矩阵 (S_t) 和类内散度矩阵 (S_w),类间散度矩阵为:
[ S_b = S_t - S_w ]
然后通过求解广义特征值问题,取若干个最大的广义特征值对应的特征向量,构成判别子空间。二分类时只能得到一个有效方向,多分类时最多可以得到类别数减一个方向。很多资料里把这种推广直接称为线性判别分析,简称LDA。实际工程里,如果类别数比较多,我倾向于先用LDA把维度压到一两个维度,再用简单的最近质心或阈值分类,效果往往比直接训练多分类逻辑回归更直观。
3. 数据准备与核心代码实现
3.1 模拟数据与预处理
理论讲完,接下来进入实操。为了便于复现,我使用某模拟项目X生成的二维数据,两个类别都从二元高斯分布采样。第一类中心在 ((2,2)),第二类中心在 ((5,4)),两类有少量重叠,但整体可分性还可以。代码生成如下:
import numpy as np from sklearn.model_selection import train_test_split rng = np.random.default_rng(42) m1 = np.array([2.0, 2.0]) cov1 = np.array([[1.0, 0.3], [0.3, 1.2]]) X1 = rng.multivariate_normal(m1, cov1, size=120) m2 = np.array([5.0, 4.0]) cov2 = np.array([[1.2, -0.2], [-0.2, 1.0]]) X2 = rng.multivariate_normal(m2, cov2, size=140) X = np.vstack([X1, X2]) y = np.concatenate([np.zeros(len(X1)), np.ones(len(X2))]) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=0 )这里需要特别注意数据预处理。Fisher推导中会用到协方差矩阵,如果特征量纲差别很大,数值大的特征会在类内散度矩阵里占据主导,投影方向可能被无关的尺度影响。实际项目里,我一般先对每个特征做z-score标准化,而且只用训练集计算出均值和标准差,再放到验证集和测试集上做变换,避免信息泄露。
mu = X_train.mean(axis=0) std = X_train.std(axis=0) + 1e-12 X_train_s = (X_train - mu) / std X_test_s = (X_test - mu) / std3.2 核心代码:Fisher判别向量计算
下面这段代码是Fisher分类器的核心,计算类内散度矩阵,加入正则可逆处理,然后求出投影方向。我通常会把数据按标签0和1拆成两个矩阵,再分别计算均值和散度。
def train_fisher(X_train_s, y_train, reg=1e-4): x0 = X_train_s[y_train == 0] x1 = X_train_s[y_train == 1] m0 = x0.mean(axis=0) m1 = x1.mean(axis=0) diff = m1 - m0 Sw = np.zeros((X_train_s.shape[1], X_train_s.shape[1])) for x in x0: d = x - m0 Sw += np.outer(d, d) for x in x1: d = x - m1 Sw += np.outer(d, d) Sw_reg = Sw + reg * np.eye(X_train_s.shape[1]) w = np.linalg.inv(Sw_reg) @ diff return w代码里用循环计算散度矩阵,效率不算高,但逻辑很清楚。每个样本减去对应类的均值,得到残差向量,再和自身外积累加。(S_w) 就是这些外积矩阵的和。对数值稳定性要求高的场景,可以用矩阵运算把散度计算写成 (X_0^T X_0) 减去均值外积的形式,速度会快一些,但初学者先看循环版更容易理解。
得到了 (w) 之后,需要确认标签0和标签1的投影顺序。因为 (w) 和 (-w) 表示同一个方向,只是正负相反,如果不统一符号,后面阈值和预测就会判反。稳妥的做法是计算两类训练样本的投影均值,如果标签0的均值大于标签1,就把 (w) 翻转。
proj0 = X_train_s[y_train == 0] @ w proj1 = X_train_s[y_train == 1] @ w if proj0.mean() > proj1.mean(): w = -w proj0, proj1 = X_train_s[y_train == 1] @ w, X_train_s[y_train == 0] @ w threshold = 0.5 * (proj0.mean() + proj1.mean())3.3 分类阈值选择与投影评估
阈值选择是Fisher分类器最容易忽略却非常影响结果的地方。如果两类投影后的方差接近,直接用投影均值的中间点就可以。但在真实项目里,两类投影方差经常不一样,类别不平衡也常见。最简单有效的办法是在验证集上做阈值搜索:
best_acc = 0 best_th = threshold for t in np.linspace(proj0.min(), proj1.max(), 200): pred = ((X_test_s @ w) > t).astype(int) acc = (pred == y_test).mean() if acc > best_acc: best_acc = acc best_th = t阈值搜索看起来笨,但在二分类任务中非常可靠,尤其适合作为分类器调优的兜底方案。如果你不想引入验证集搜索,也可以用投影后的每类均值和标准差去拟合高斯曲线,然后解贝叶斯阈值方程。不过对于大多数工程场景,搜索几百个候选阈值花费的时间可以忽略不计,结果也更直观。
3.4 完整可运行脚本
把上面的逻辑整合成一个完整脚本,方便直接复制修改:
import numpy as np from sklearn.model_selection import train_test_split def train_fisher(X_train_s, y_train, reg=1e-4): x0 = X_train_s[y_train == 0] x1 = X_train_s[y_train == 1] m0 = x0.mean(axis=0) m1 = x1.mean(axis=0) diff = m1 - m0 Sw = np.zeros((X_train_s.shape[1], X_train_s.shape[1])) for x in x0: d = x - m0 Sw += np.outer(d, d) for x in x1: d = x - m1 Sw += np.outer(d, d) Sw += reg * np.eye(X_train_s.shape[1]) w = np.linalg.inv(Sw) @ diff proj0 = x0 @ w proj1 = x1 @ w if proj0.mean() > proj1.mean(): w = -w proj0, proj1 = x1 @ w, x0 @ w threshold = 0.5 * (proj0.mean() + proj1.mean()) return w, threshold rng = np.random.default_rng(42) X1 = rng.multivariate_normal([2.0, 2.0], [[1.0, 0.3], [0.3, 1.2]], 120) X2 = rng.multivariate_normal([5.0, 4.0], [[1.2, -0.2], [-0.2, 1.0]], 140) X = np.vstack([X1, X2]) y = np.concatenate([np.zeros(120), np.ones(140)]) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=0 ) mu = X_train.mean(axis=0) std = X_train.std(axis=0) + 1e-12 X_train_s = (X_train - mu) / std X_test_s = (X_test - mu) / std w, threshold = train_fisher(X_train_s, y_train, reg=1e-4) pred = (X_test_s @ w > threshold).astype(int) acc = (pred == y_test).mean() print(f"w = {w}") print(f"threshold = {threshold:.4f}") print(f"test accuracy = {acc:.4f}")这个脚本在我本地固定随机种子下,得到的投影方向大致指向两类中心连线方向,测试准确率在九成以上。数据量、随机种子不同,具体数值会有波动,但整体趋势是稳定的。
4. 实验过程与结果分析
4.1 实验一:两类重叠较小的模拟数据
使用上面生成的数据,第一类120个样本,第二类140个样本,两类中心距离大约在 ((3,2))。经过标准化后,我跑出的投影向量大致形如 ([0.82, 0.57]) 这样的方向,说明Fisher把更多权重放在了第一维上,这和第一维的类间差异更大有关。训练集投影后,标签0的均值明显小于标签1,阈值落在两类投影分布之间,测试集准确率约在0.92到0.94之间。
这个结果符合预期。Fisher准则本质上是在数据分布的均值和协方差信息上做最优线性投影,当两类协方差相差不大时,得到的投影方向接近于两类中心连线的方向,分类边界也接近垂直平分线。此时投影后的直方图应该呈现双峰形态,中间重叠区域很小,说明特征可分性足够。
4.2 实验二:协方差差距较大时的表现
为了看看Fisher分类器的极限,我调整了第二类的协方差,让它远大于第一类。比如第一类协方差设置为 ([0.2,0;0,0.2]),第二类协方差设置为 ([4,0;0,4]),两类中心仍然保持一定距离。这种情况下,Fisher依然能找到投影方向,但分类效果明显变差,测试准确率会掉到八成左右,甚至更低。
原因在于,Fisher目标函数的分母是两类类内散度之和。如果某一类样本特别分散,分母会被这一类主导,投影方向会努力把分散的那一类压得更紧,而不是单纯拉开两类中心。如果两类协方差结构差异太大,线性投影很难同时满足“类间距离大”和“类内距离小”。这个实验说明,Fisher准则并不自动适配异方差情况,面对这类数据,可以考虑先把特征做非线性变换,或者改用二次判别分析。
4.3 从实验结果读出什么
我每次做完Fisher实验,都会做两件事:第一,画出投影后的直方图或者一维散点图,直观判断两类样本是否还有重叠;第二,检查 (w) 的方向和特征维度的关系,看哪些特征对分类贡献大。比如二维特征里,如果 (w) 的第一个分量远大于第二个分量,说明第一维特征对区分两类更重要。这个信息可以帮助后续做特征筛选。
还有一点值得强调,Fisher只用到了均值和协方差,这些统计量对离群点比较敏感。如果数据里有少量明显错误样本,均值和协方差都会被带偏,投影方向就会失真。所以实验前我一般会先做离群点筛查,至少绘制各类样本的箱线图看一下分布范围。
5. 常见问题与排查技巧实录
5.1 类内散度矩阵奇异
这是我被问得最多的问题。当特征维度大于样本数量,或者特征之间存在高度相关时,(S_w) 会接近奇异,直接求逆会出现巨大数值。解决方案有三个:一是加正则化项,在 (S_w) 上叠加 (\lambda I);二是先用PCA降维,把特征压到样本量以下;三是使用伪逆,也就是奇异值分解后把零奇异值置为很小的数再求逆。
正则化参数 (\lambda) 的取值需要根据特征尺度调整。如果特征已经标准化,我通常从 (1 \times 10^{-4}) 开始试,最多到 (1 \times 10^{-2})。太小了数值不稳定,太大了投影方向会被单位阵主导,逐渐退化成直接用均值差分类。你可以观察矩阵条件数,如果条件数超过 (10^6),就要警惕数值问题。
5.2 投影结果与直觉相反
有时候你明明觉得代码没问题,但预测结果和想象完全相反,比如准确率只有零点几。最大的可能有两个:一是标签0和标签1的顺序反了,二是 (w) 的符号导致投影方向翻转。这类问题很好查,把训练集的投影结果分别打印出来,看两类均值的大小关系,再对齐阈值判断逻辑就行。我习惯统一约定“投影值大于阈值被预测为标签1”,这样排查起来省很多时间。
5.3 分类阈值总是不准
即使投影方向正确,阈值选不好也会严重影响准确率。直接使用两类投影均值的中点,隐含假设是两类投影后方差相等,而且先验概率也相等。真实数据里,要么类别不平衡,要么某一类特别分散,中点就会偏向噪声多的那一侧。更可靠的做法是使用验证集做阈值搜索,也可以根据先验概率调整:
[ th = \frac{w^T m_1 + w^T m_2}{2} + \frac{\log(p_2/p_1)}{w^T (m_1-m_2)?} ]
这个公式推导起来比较绕,线上系统里我一般直接用搜索阈值,既简单又直观。阈值搜索的粒度可以先粗后细,第一轮分成100段找粗略区间,第二轮再在最优区间附近细分。
5.4 多分类与线性不可分问题
Fisher准则用线性投影表达类别差异,遇到XOR这类非线性可分问题时,直接做效果很差。如果你发现训练和测试准确率都很低,而且投影直方图两类完全混在一起,就别硬调Fisher了,考虑给特征加多项式展开,或者换核方法。多分类时,一对多策略容易引入类别不平衡,一对一策略训练次数较多。条件允许的话,直接用多分类LDA,求出判别子空间后配合最近质心分类,通常更稳定。
5.5 问题排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 矩阵求逆报奇异或结果爆炸 | 特征维度高或特征相关性强 | 加正则项,用伪逆,先PCA降维 |
| 测试准确率接近0 | 标签顺序或 (w) 符号反了 | 打印两类投影均值,统一正负约定 |
| 训练准确率高但测试低 | 过拟合,正则化不足 | 增大正则项,增加样本量,减少特征 |
| 阈值怎么调都低 | 特征可分性差或分布非线性 | 看投影直方图,尝试特征扩展或换模型 |
| 某类完全被分错 | 类别不平衡 | 调整阈值,换用约登指数或F1搜索 |
这张表是我在项目debug过程中慢慢积累出来的。遇到Fisher分类器效果不好,先不要怀疑数学公式,大部分时候都是数据、符号、正则化这三类问题。
6. 工程细节与个人调参心得
6.1 特征标准化是隐形前提
很多人照着网上的公式写完代码,直接拿原始特征去算 (S_w) 和 (S_b),结果发现投影方向被量纲大的特征带偏。举个极端例子,一个特征范围在0到1,另一个特征范围在0到10000,第二个特征的协方差天然就大,类内散度矩阵里它占主导,Fisher会把这个特征当成主要区分方向,哪怕它实际没有判别力。所以标准化不是可选项,而是必要的前置步骤。如果你用了正则化,标准化后更容易选择统一的 (\lambda) 范围,不用每个特征单独调。
6.2 正则项怎么加
正则化的核心是让 (S_w + \lambda I) 变得可逆且稳定。理论上 (\lambda) 应该尽量小,保留数据本身的协方差结构;但太小了数值条件数太大,求逆结果不可靠。我的一般流程是:先做特征标准化,然后试 (\lambda = 0, 1e-6, 1e-4, 1e-2),观察训练准确率和验证准确率。如果 (\lambda=0) 能正常跑,就先用0;如果报奇异,就从 (1e-6) 起步。注意正则化不能解决因样本量太少导致的所有问题,如果样本只有几十个而特征有上百维,加一点正则也是杯水车薪,更需要考虑特征选择。
6.3 和PCA的区别
Fisher准则和主成分分析虽然在代码上很像,本质却完全不同。PCA是无监督方法,它找的是方差最大的方向,不关心类别标签;Fisher是有监督方法,它找的是能让类别分开的方向。有时候某个方向PCA方差很大,但两类样本在这个方向上的投影完全重叠,对分类毫无帮助。反之,Fisher选出的方向可能方差不大,但投影后两类样本分得清清楚楚。所以千万不要用PCA结果去替代Fisher,两者解决的问题不一样。
6.4 什么场景下不建议使用Fisher
如果数据类别严重不平衡,Fisher会遇到小样本类别的协方差估计不稳定问题;如果特征大多是离散计数,均值和协方差的表达能力也有限;如果数据分布存在明显非线性边界,线性Fisher无论如何调整阈值都很难拉高上限。这些场景下,我会把Fisher当成一个诊断工具而不是最终模型:先算一下投影后两类样本的可分性,如果连Fisher都分不开,再考虑升级模型也不迟。
在实际项目中,我经常用Fisher做第一版快速原型。它的好处是跑得快、可解释、参数少,能很快告诉大家当前特征够不够用。如果在Fisher投影后的直方图里已经能明显看到两个峰,那后面上稍微复杂一点的模型,效果通常也不会太差。如果Fisher投影后一片混乱,那问题多半不在分类器,而在特征本身。先花十几分钟跑一版Fisher,往往比直接调深度学习模型参数要省下更多时间。