简介:基于Python实现Fisher线性判别的模式识别资源,面向机器学习初学者与模式识别课程学生,重点演示二分类问题中如何选取投影方向,使得类间距离尽可能大、类内样本尽可能聚集,并附完整代码与实验数据。压缩包共14个文件,大小约1.02MB,主要包含两个Python脚本、Iris和Sonar两组经典数据集(含data/rocks/mines等辅助文件)、PDF报告、Word文档及Markdown说明,覆盖算法实现、数据预处理、结果分析等环节。其中Sonar数据区分岩石与水雷两类样本,Iris数据则用于经典鸢尾花分类验证,代码中详细标注了投影向量与分类阈值的计算过程。已有250人浏览学习。通过参考源码可复现Fisher判别流程,利用自带数据验证分类效果,结合文档理解分类面确定原理,并可根据实际任务替换数据、调整参数,适合作为模式识别课程设计或期末大作业的参考资料。
1. 从“找方向”到“找分类方向”:Fisher 线性判别解决什么问题
做过特征工程的人多半都有这种经历:手上有 10 个特征,PCA 压缩到 2 维后可视化很漂亮,可拿去做分类准确率反而掉了。原因不是 PCA 算法错了,而是它根本不看标签——它最大化的只是“方差大”,方差大不代表类别能分开。模式识别里的 Fisher 线性判别(FLD)走的是另一条路:它监督式地把样本投影到一个方向上,让两类在投影后“类间距离尽量大、类内聚集尽量紧”,既可以直接用于分类,也可以当作有监督降维的预处理。下面我会先用 Python 的 NumPy 把 Fisher 完整算一遍,再到 scikit-learn 验证,把原理、参数、避坑一条线讲完。适合正在做模式识别课程实验、或者想把分类特征“压缩但不丢判别信息”的工程师。
2. Fisher 线性判别的数学原理:为什么“类间大、类内小”就是最优投影
2.1 把高维样本压到一维再看分类
Fisher 线性判别最早是为了解决二分类问题提出的:把 d 维样本 x 通过线性投影 y = w^T x 变成一维标量,然后在 y 上找阈值做分类。为什么不是直接在原始空间里画分隔超平面?高维空间里样本分布复杂,而投影到一维后,所有信息被压缩到一个方向上,只要这个方向选得好,分类就变成“在一根数轴上找分界点”,简单、计算量小,也直观。
选方向的标准就是 Fisher 在 1936 年提出的判据:两类投影后的均值之差尽量大,类内方差尽量小。写成数学式就是最大化:
J(w) = (w^T (m1-m2)(m1-m2)^T w) / (w^T (S_w1+S_w2) w)
这里的 m1、m2 是两类样本的均值向量,S_w1、S_w2 是两类各自的“类内散度矩阵”(对每一类样本减去均值后的外积求和,和协方差矩阵只差一个常数分母)。分子刻画“两类中心拉开的程度”,分母刻画“每个类里样本分散的程度”。Fisher 的直觉被后人总结成一句话:类间散度要大、类内散度要小,两者比值最大时,投影后的数据最好分。
这个式子本质上是一个广义瑞利商。如果你学过 PCA,会看到两者形式很像,但 PCA 的分子是整体样本方差,没有任何标签信息;Fisher 的分子和分母都依赖类别划分,所以它是“有监督的”。这也是为什么在实际项目里,PCA 压缩后分类效果未必变好,而 Fisher 压缩后的特征往往能保持甚至提升分类边界清晰度——它从一开始就在找“能区分类别”的方向。
2.2 两类情况的闭式解:w = S_w^{-1}(m1 - m2)
对 J(w) 求导并令导数为零,可以得到一个非常干净的结果:最优投影方向 w 满足
S_w w = m1 - m2,也就是 w = S_w^{-1}(m1 - m2)
其中 S_w = S_w1 + S_w2 是总的类内散度矩阵。推导过程不复杂:把 J(w) 写成瑞利商形式后,最优解就是广义特征值问题的最大特征向量;对两类情况,S_b = (m1-m2)(m1-m2)^T 的秩是 1,这个特征向量恰好就是 S_w^{-1}(m1-m2),连特征值都不用解。
所以严格说,Fisher 判别并不需要像神经网络那样迭代训练。你要做的事情就是三步:算各类均值、累加组内散度矩阵、解一个线性方程组。这也是这个算法在几十年前计算资源极度匮乏时就能被用起来的原因。对于工程师来说,这同时意味着一个很现实的提醒:Fisher 判别能落地的前提是 S_w 可逆。一旦特征数超过样本数,或者两个特征高度线性相关,S_w 就变成奇异矩阵,后面求逆那一步会直接报错,这个坑我放在第 5 章单独讲。
2.3 多分类推广:为什么最多只能降到 C-1 维
二分类 Fisher 判别很好理解,多分类时怎么办?LDA(Linear Discriminant Analysis)就是 Fisher 判别的多类推广。对于 C 个类别,Fisher 不再找一条直线,而是找一组投影方向,让所有类投影后“整体分离度”最大;最终得到的是一个 d 行、C-1 列的投影矩阵 W。
为什么最多只有 C-1 个方向?因为类间散度矩阵 S_b 在多类情况下是由 C 个类的均值和全局均值构造的,它的秩最大等于 C-1。线性代数的结论是:秩有多少,非零特征向量最多就有多少。所以你在用 sklearn 的 LinearDiscriminantAnalysis 做降维时,n_components 的上限是 C-1。三分类最多降到 2 维,五分类最多降到 4 维。
这个上限不是算法偷懒,它反映了 Fisher 判别的本质:它只关心“类中心之间”的差异,类中心的自由度就是 C-1。如果你想保留类内部的细节结构,比如在高维点云里区分同一类中的子簇,Fisher 就不合适,那应该考虑无监督的 PCA 或者非线性降维。模型没有好坏,只有用对没用对。
3. 从零实现 Fisher 线性判别:用 NumPy 算投影方向的完整代码
3.1 造一批二维数据:先看到分布再谈判别
实验先从造数据开始。我们生成两类高斯分布样本,每类 80 个,二维特征,均值选在 [2, 3] 和 [6, 5],协方差矩阵带有正相关(0.8),这样两类会有重叠,Fisher 不会得到 100% 准确率,但能看出投影的作用。数据生成用 np.random.default_rng 固定随机种子,保证可复现;画散点图时把类别颜色区分开。
import numpy as np import matplotlib.pyplot as plt rng = np.random.default_rng(42) m1 = np.array([2.0, 3.0]) m2 = np.array([6.0, 5.0]) cov = np.array([[1.2, 0.8], [0.8, 1.1]]) X1 = rng.multivariate_normal(m1, cov, size=80) # 类别1 X2 = rng.multivariate_normal(m2, cov, size=80) # 类别2 y1 = np.zeros(80, dtype=int) # 类别标签 0 y2 = np.ones(80, dtype=int) # 类别标签 1 plt.figure(figsize=(6, 5)) plt.scatter(X1[:, 0], X1[:, 1], c='steelblue', label='class 1', alpha=0.7) plt.scatter(X2[:, 0], X2[:, 1], c='tomato', label='class 2', alpha=0.7) plt.xticks(np.arange(0, 10, 2)) plt.yticks(np.arange(0, 10, 2)) plt.xlabel('feature 1') plt.ylabel('feature 2') plt.legend() plt.title('Two gaussian classes with overlap') plt.show()这里用 rng.multivariate_normal 而不是老式的 np.random.multivariate_normal,是为了不污染全局随机状态,方便你换种子复现。散点图的刻度我用 np.arange 手动设置,避免 matplotlib 自动刻度在样本重叠区域显示得太密,这是这类可视化经验里很常见的一个小点——横坐标刻度密集的时候,先想到 xticks 而不是改画布大小。
3.2 计算类内散度矩阵与投影方向 w
数据处理的核心代码在这段。先把每个类别的样本中心化(样本减去本类均值),然后累加每个类的散度矩阵 S_w1、S_w2,用矩阵乘法 X.T @ X 实现外积求和;类间散度 S_b 用两个均值向量之差的外积 np.outer 构造;最后解线性方程组 np.linalg.solve(S_w, d) 得到 w。代码里我特意用 solve 而不是 inv 求逆后再点乘,原因后面说。
def fisher_w(X1, X2): # 类均值向量 m1 = np.mean(X1, axis=0) m2 = np.mean(X2, axis=0) # 类内散度矩阵:中心化样本的外积之和,即每个类的散布矩阵 X1_centered = X1 - m1 X2_centered = X2 - m2 Sw1 = X1_centered.T @ X1_centered Sw2 = X2_centered.T @ X2_centered Sw = Sw1 + Sw2 # 类间散度矩阵(两类情况下秩为1) d = m1 - m2 Sb = np.outer(d, d) # 最优投影方向:w = Sw^{-1} (m1 - m2) w = np.linalg.solve(Sw, d) # 按需归一化长度,只影响投影值的尺度,不影响方向 w = w / np.linalg.norm(w) m1_proj = m1 @ w m2_proj = m2 @ w return w, m1_proj, m2_proj w, m1_proj, m2_proj = fisher_w(X1, X2) print("w:", w) # 投影方向 print("投影类均值:", m1_proj, m2_proj)S_w 是 2x2 的矩阵,np.linalg.solve 直接解 Sw @ w = d,数值上比先算 inv(Sw) 再乘 d 更稳定。你会发现代码里没有显式构造 S_b 参与求解——两类时 S_b 信息完全包含在 d 里,所以简化掉了,但这个变量保留在代码中能提醒你它与公式的对应关系。输出的 w 我做了向量归一化,归一化只改变投影值的尺度,不改变方向,也不影响后续分类阈值的位置(阈值是相对值)。
投影方向画出来是二维平面上的一条线。把 w 的两个分量当作方向向量,从原点画到 (w[0], w[1]),再画一条经过两个类中心的直线,就能看出数据在哪个方向分离程度最大。很多教程在这一步就停下来,只给了一个方向向量——但这对工程师来说不够,我们还要用它完成分类。
3.3 投影、定阈值、算准确率:一个完整分类器
有了 w,分类流程就变成纯一维操作:每个样本 x 投影成标量 y = x @ w,两类投影分布各自的均值记为 m1_proj、m2_proj;简单的分类阈值取两个投影均值的中点 (m1_proj + m2_proj) / 2。预测时 y 大于阈值判给类 2,否则判给类 1。
X_all = np.vstack([X1, X2]) y_all = np.concatenate([y1, y2]) proj = X_all @ w # 所有样本投影到 w 方向 thr = (m1_proj + m2_proj) / 2 # 阈值取两个投影类均值的中点 pred = (proj > thr).astype(int) # 大于阈值判为类2,否则为类1 acc = np.mean(pred == y_all) print("Fisher 判别准确率:", acc) # 用两个原始特征直接按最近类中心分类,做个基准对比 dist1 = np.linalg.norm(X_all - m1, axis=1) dist2 = np.linalg.norm(X_all - m2, axis=1) pred_raw = (dist2 < dist1).astype(int) acc_raw = np.mean(pred_raw == y_all) print("原始特征最近中心分类准确率:", acc_raw)这里的分类等价于在原空间里找一个垂直于 w 的超平面作为决策边界,决策边界过两个类投影均值的中点。Fisher 的意义在于:它把二维甚至更高维的分类问题化简为一维阈值问题,在这个人工数据上准确率通常会稳定在 85%-90% 区间;如果生成的两类均值离得更远,准确率接近 100%,但你能清楚看到投影后的判别边界在哪里。
有个细节值得注意:acc 的计算直接用了全部样本,没有区分训练集和测试集。这种“在训练数据上评估自己”的做法在 Fisher 这种无超参数模型上还算能看,但真实项目中我强烈建议至少做一次留一法交叉验证——第 6 章会给完整代码。因为 Fisher 的 w 会被少数极端样本明显拉动,单次划分评估很容易给你的论文或实验报告一个过度乐观的数。
4. 用 scikit-learn 复现 Fisher 判别:LDA 分类与降维两种用法
4.1 LinearDiscriminantAnalysis 就是 Fisher 判别的工程实现
手写版本帮你建立了完整的计算图:均值 → 类内散度 → 求解 w → 投影 → 阈值。scikit-learn 的 sklearn.discriminant_analysis.LinearDiscriminantAnalysis 就是这套思路的多类、工程化版本。它的默认 solver 是 'svd',不做矩阵求逆而用奇异值分解来求解广义特征问题,因此即使 S_w 秩亏,默认参数下通常也不会像手写版那样直接报 LinAlgError,对小样本高维数据友好得多。
这也意味着,如果你只是想快速得到一个 Fisher 判别分类器,不必重复造轮子;但如果你要写课程报告或者调试一个“为什么 LDA 结果和 PPT 上不一样”的问题,手写版能帮你把中间量一个个打出来。工程上我推荐两条腿走路:手写版验证理论,sklearn 版跑数据和上线。
# 确认环境里已安装所需的库;缺哪个就 pip install 哪个 # pip install numpy scikit-learn matplotlib from sklearn.discriminant_analysis import LinearDiscriminantAnalysis lda = LinearDiscriminantAnalysis(solver='svd') # 默认就是 svd,这里显式写出 lda.fit(X_all, y_all) print("投影方向 w(两类):", lda.coef_ / np.linalg.norm(lda.coef_)) print("准确率:", lda.score(X_all, y_all))4.2 鸢尾花数据集上的完整分类流程
真实数据集更说明问题。鸢尾花是最常用的多类判别例子:150 个样本、4 个特征、3 个类别。先用两个特征(花萼长宽)做可视化,跑通 fit / predict / score;再用全部特征看准确率提升,顺便看一眼混淆矩阵,判断错误主要发生在哪两类之间(山鸢尾和杂色鸢尾其实线性可分,但杂色和维吉尼亚花在部分特征上重叠)。
from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.metrics import confusion_matrix, accuracy_score from sklearn.discriminant_analysis import LinearDiscriminantAnalysis iris = load_iris() X_all4 = iris.data # 全部4个特征 X_2f = iris.data[:, :2] # 前两个特征,便于二维可视化 y = iris.target # 统一划分:stratify 保证每个类在训练/测试里的比例一致 X_train, X_test, y_train, y_test = train_test_split( X_2f, y, test_size=0.3, stratify=y, random_state=1) lda2 = LinearDiscriminantAnalysis() lda2.fit(X_train, y_train) pred2 = lda2.predict(X_test) print("两特征准确率:", accuracy_score(y_test, pred2)) print(confusion_matrix(y_test, pred2)) X_train4, X_test4, _, _ = train_test_split( X_all4, y, test_size=0.3, stratify=y, random_state=1) lda4 = LinearDiscriminantAnalysis() lda4.fit(X_train4, y_train) pred4 = lda4.predict(X_test4) print("四特征准确率:", accuracy_score(y_test, pred4)) print(confusion_matrix(y_test, pred4))这段代码能直接看到两个信息:一是 LDA 输出的是类别而不是降维坐标,默认情况下 fit 之后 predict 给出类别;二是 sklearn 在多类情况下会自动找齐 C-1 个方向,你不用手动管。coef_是每个特征的权重,在二分类时它的方向就对应手写版的 w。
参数说明值得单独列一下:solver='svd' 不需要求逆,是稳健默认项;solver='lsqr' 或 'eigen' 可以配合 shrinkage 使用,用于高维小样本的收缩正则;shrinkage='auto' 会自动估计收缩强度,在特征数接近样本数时很有用;priors 默认为 None(按训练集类频率推断),如果你的上线场景类别比例和训练集差异很大,要手动传 priors。注意 shrinkage 只有 lsqr 和 eigen 两个 solver 支持,svd 不支持。
4.3 把 LDA 当作有监督降维:n_components 的用法
分类只是 LDA 的一半用途。另一个常见需求是有监督降维:把原始 4 维特征压缩到 2 维,然后喂给其他分类器(比如 kNN)或者直接画散点图做探索性分析。三分类对应 C-1=2,n_components 设为 2,正好降到二维可画。
lda_embed = LinearDiscriminantAnalysis(n_components=2) X_lda = lda_embed.fit_transform(X_all4, y) # 返回降维后的坐标 plt.figure(figsize=(6, 5)) for label, color, marker in zip([0, 1, 2], ['steelblue', 'tomato', 'seagreen'], ['o', 's', '^']): mask = y == label plt.scatter(X_lda[mask, 0], X_lda[mask, 1], c=color, marker=marker, label=iris.target_names[label], alpha=0.8) plt.xlabel('LD1') # 第1线性判别方向 plt.ylabel('LD2') # 第2线性判别方向 plt.legend() plt.tight_layout() plt.show()降维后的散点图你会发现三个类明显分开,山鸢尾聚成一小团,杂色和维吉尼亚在两个方向上拉开。这就是 Fisher 判别的价值体现:它把原始特征里与分类无关的冗余方向扔掉,只保留最能区分类别的 2 个方向。对比 PCA 的降维图,LDA 的图上类别边界更清晰,因为它计算时就用了类别标签。
如果你要在生产代码里把数据路径和参数做成命令行可配置,我一般会在入口加 argparse,把数据路径、solver、n_components 都串起来;这样换数据集时不用改脚本,也方便同事复用你的实验配置。
5. Fisher 判别实战避坑:五个最容易翻车的地方
先说总体感受:Fisher 判别公式简单,代码量少,但在真实数据上翻车的点几乎都集中在“矩阵求逆、数据预处理、类别不均衡”这三类问题上。下面五条是我自己踩过、也看别人踩过的典型坑,按现象到解决一条条过。
5.1 Sw 奇异:求逆报错的最典型场景
现象:手写版本里 np.linalg.solve(Sw, d) 抛 LinAlgError: Singular matrix,或者 sklearn 模型在加 shrinkage 之前准确率诡异得低。
原因:Fisher 判别需要求 S_w 的逆,而 S_w 是 d×d 的矩阵。当特征维度 d 大于样本数,或者两个特征近似线性相关(比如图像像素邻近点几乎完全一样)时,S_w 不满秩。也就是说,方程 S_w w = d 有无穷多解或没有解。
解决:按优先级试试这三招:先加样本,或者做特征筛选,把高度相关的特征去掉;手写版把 np.linalg.solve 换成 np.linalg.pinv 求伪逆,能得到一个最小范数解,分类效果通常仍然可用;工程上更推荐直接用 sklearn 的 LinearDiscriminantAnalysis(solver='svd'),它绕开了求逆,天然免疫这个问题。如果坚持用 eigen 求解,就把 shrinkage 打开。
5.2 特征量纲不一致让判别方向“偏科”
现象:准确率不差,但你画出 w 后发现某个特征分量极大,其他特征几乎被忽略;换一批测试数据后准确率抖动很厉害。
原因:Fisher 的散度矩阵是基于欧氏距离构造的,特征量纲天然占优的那个会主导类间差异。比如一个特征取值范围 0-100,另一个只有 0-1,前者对散度矩阵的贡献是后者的上万倍,投影方向基本就被它带跑了。
解决:训练前用 StandardScaler 把每个特征标准化到零均值、单位方差,再算 Fisher。这是手写版和 sklearn 版都要养成的前提动作。注意用 fit 在训练集上算均值和方差,再用同一组参数 transform 验证集,防止数据泄露。
提示:如果你做的是图像像素这类天然同量纲数据,标准化不是必需的;但对混合量纲特征,不标准化翻车概率极高。
5.3 多分类只降到一维,白白丢信息
现象:三分类数据用 sklearn 的 LinearDiscriminantAnalysis() 默认分类,和 PCA 降到 1 维再分类比,准确率低;有人为了画一维分布图把 n_components 设成 1,结果三类重叠严重。
原因:多分类时 Fisher 的最大有效投影方向数是 C-1。三分类最多两维,强行压到一维等于扔掉了一半以上的类间信息,相当于用一条线去分三个团,自然分不开。
解决:明确你是在做“分类”还是“降维”。做分类时不要设 n_components,让 LDA 在 C-1 维判别空间里决策;做降维可视化时按 C-1 设上界,三分类设 2,五分类设 4。如果只是想看一维分布,建议先降到 2 维再观察,而不是直接设 1。
5.4 两类样本不均衡时,中点阈值会偏向多数类
现象:训练数据类 1 有 2000 条、类 2 有 100 条,Fisher 分类器在训练集上准确率 92%,但上线后新样本全都判成类 1。
原因:手写版里的阈值取的是两个投影均值的中点 (m1_proj + m2_proj)/2,这个公式隐含假设两类先验概率相等。样本不均衡时,类 2 的均值估计被少量样本拉得不准,中点也离多数类更近,边界明显偏向少数类一侧,于是多数类被大面积误判。
解决:用 sklearn 版时显式传 priors,比如 [0.5, 0.5] 告诉模型“业务上两类同等重要”;或者先过采样/欠采样把训练集调均衡再训练。手写版也可以自行把阈值从均值中点改成两个投影分布的似然分界点(在等协方差高斯假设下等价于把先验比的对数加到阈值上),但这套推导对新手不友好,不如直接换 sklearn。
5.5 投影分布图横坐标太密集,判别边界看不清楚
现象:把投影值画直方图或散点图时,横坐标刻度挤成一堆小数,类内点叠成一坨,阈值位置和重叠区域完全看不出来。
原因:Fisher 算出的投影值通常落在原始特征的量纲区间内,如果原始特征单位很大(比如像素值 0-255 或经纬度),投影标度也很大,matplotlib 自动刻度会生成大量刻度标签;再加上两类重叠区的点本来就密集,看不出分布形态。
解决:可视化前把投影值缩放到 [0, 1] 区间(min-max),或者干脆画直方图而不是散点图,用 bins=40 观察两峰的位置;刻度密集时用 plt.xticks(range(0, plot_max, step)) 或 plt.locator_params(axis='x', nbins=12) 控制数量。这一步不改变模型本身,但能帮你在写报告时一眼发现“两类其实重叠得很厉害”这个真相。
6. 让 Fisher 判别更可靠的验证技巧:留一法 + 收缩正则
我在实际项目中把小样本、高维度的特征喂给 Fisher 时,最常用的验证方式不是单次 train_test_split,而是留一法(LOOCV),样本量小于 50 时尤其如此。理由很简单:Fisher 的 w 完全来自均值和散度矩阵,对单个极端样本非常敏感,如果单次划分碰巧把几个边界样本都放进测试集,score 会忽高忽低,看起来像“参数在飘”。
from sklearn.model_selection import LeaveOneOut, cross_val_score loo = LeaveOneOut() scores = cross_val_score(lda4, X_all4, y, cv=loo) print("留一法平均准确率:", scores.mean())对小数据集,留一法的结果比 7:3 划分稳定得多,代价是计算量线性增加,但 LDA 本身很快,150 个样本完全无所谓。另一个实用习惯是,发现 S_w 可能病态时打开收缩正则:LinearDiscriminantAnalysis(solver='eigen', shrinkage='auto')。它会给散度矩阵对角线加一个很小的值,让求逆稳定,代价是引入轻微偏差。一般来说,样本量低于特征维度 3 倍时我默认开启。
我自己的一个血泪经验是:手写版和 sklearn 版的准确率对不上时,先查标准化有没有对训练集和验证集分别做,再查 priors 是否一致——这两处最容易翻车。查完这两个,百分之八十的偏差都能解释。你可以把留一法这段代码存成模板,以后每个实验都先跑一遍看稳定性,再决定要不要深入调参。希望帮到你。
本文还有配套的精品资源,点击获取