我最早接触 Iris 数据集那会儿,第一反应是“太好了,不用做数据清洗了”。但真正上手把它画出来、分出来,才发现事情没那么简单:四个特征,三类各 50 个样本,放在一起就是一朵有三套花萼花瓣的数据云。你没法像二维那样把所有信息平铺在一张图上,更没有任何单张散点图能把三个类彻底分开。这才是 Fisher 线性分类真正打动我的地方——它先把四维压成能看的二维,再在低维空间里做分类与判断。无论你是做课程设计,还是第一次正经跑完一个完整项目,把 Fisher 线性判别和数据可视化组合起来走一遍,都会比单独背一个 Logistic 回归原理扎实得多。
很多人一上来就直接调用别人封装好的分类器,跑出一个准确率,项目就算完了。但我建议你换个姿势:自己动手算一遍 Fisher 判别,把一步步中间结果打出来,再画成图。你会发现那些“理所当然”的分类边界,其实是几个散度矩阵互相角逐的结果。这篇文章不绕弯子,直接用 Python + NumPy + Matplotlib 把 Iris 从原始四维数据做到 Fisher 投影、二维可视化、决策边界画图,完整记录我实现过程中的理解和踩过的坑。
1. 为什么选 Iris 练手,以及 Fisher 判别适合解决什么问题
1.1 数据集的分寸感:一类完全可分,两类天然交错
Iris 数据集一共 150 条样本,每条样本对应一朵鸢尾花,三个品种各 50 条,特征是花萼长度、花萼宽度、花瓣长度、花瓣宽度,单位都是厘米。说它“干净”是真的干净,没有缺失值,也没有乱七八糟的文本特征;说它有挑战也是真有挑战——山鸢尾(Setosa)和另外两类在花瓣特征上几乎一刀就能切开,但杂色鸢尾(Versicolor)与维吉尼亚鸢尾(Virginica)在花瓣长度上有一段明显的重叠区。
这个“重叠区”恰恰是这个项目最大的教学价值所在。你会发现,单取任何一个原始特征,都无法做到三分类的彻底分离;取两个特征画散点图,也顶多是把 Setosa 隔离出来,剩下两类照样缠在一起。于是必须想一个办法,把四个特征的判别信息做某种组合,找到最能拉开类别的方向——这就是 Fisher 线性判别的核心使命。
1.2 Fisher 判别在整个分类方法坐标系里的定位
在机器学习框架里,分类方法通常分成三类:有监督的判别模型(如逻辑回归、SVM、神经网络),有监督的生成模型(如朴素贝叶斯、高斯判别分析),以及无监督的聚类方法(如 KMeans)。Fisher 线性判别属于一个有监督的降维过程,它的输出不是最终的类标号,而是一组低维特征,你可以在这组低维特征上继续接任何分类器。
Fisher 的名字很诚实:它尝试在高维空间里寻找一个或一组线性方向,让样本投影到这个方向后,类间距离尽量大、类内距离尽量小。它既不是简单的主成分分析,也不是端到端的分类器,它是“先找判别方向,再在低维空间里做判断”的桥。Iris 是四维数据,Fisher 找到的有用方向最多只有类别数减一,也就是两个,而这恰好构成了一张二维平面图。这也解释了为什么很多教材里会把 Iris 和 Fisher 放在一起讲——它们是天生的搭配。
2. Fisher 线性判别的核心原理拆解
2.1 从“押大小”说清楚 Fisher 的思想
想象你在一个广场上,面前站着三群人,每群人都蒙着眼,你只能通过他们脚下的 GPS 坐标判断他们属于哪一群。最容易想到的做法是看每一群的平均位置,然后画一条线。但如果这群人彼此站得很散,像三团云一样互相渗透,只用均值就会判断错误。Fisher 的思路是:先不看原坐标,把所有人往某一条直线上“拍扁”,也就是把二维投影成一维,然后比较投影后的均值差与方差。
这条直线不能乱选。如果选的线和人群分布的方向垂直,投影后所有人都挤在一起,均值拉不开;如果选的线刚好沿着人群聚集的方向,投影后每群内部很紧凑,同时群和群之间隔得很远。Fisher 判别要做的,就是把“群间距离尽可能大”和“群内距离尽可能小”这两个目标同时放进一个统一的函数里,然后找到让函数最大的那条直线。对 Iris 来说,这个“直线”变成了一个二维平面,因为三分类需要两个方向。
2.2 数学推导:类内散度、类间散度与广义瑞利商
先定义几个记号。假设样本有 $K$ 个类别,第 $k$ 类的样本集合是 $\mathcal{C}_k$,样本总均值记作 $\mu$,第 $k$ 类的均值记作 $\mu_k$。Fisher 判别需要构造两个矩阵:
类内散度矩阵 $S_W = \sum_{k=1}^{K} \sum_{x \in \mathcal{C}_k} (x - \mu_k)(x - \mu_k)^T$,它衡量的是每个类别内部的离散程度。
类间散度矩阵 $S_B = \sum_{k=1}^{K} n_k (\mu_k - \mu)(\mu_k - \mu)^T$,其中 $n_k$ 是第 $k$ 类的样本数,它衡量的是各类中心相对全局中心的总偏离。
Fisher 的目标是寻找一个投影方向向量 $w$,使得投影后的类间散度尽量大、类内散度尽量少,也就是最大化广义瑞利商:
$$ J(w) = \frac{w^T S_B w}{w^T S_W w} $$
这是一个广义特征值问题:对 $S_W^{-1} S_B$ 做特征分解,取特征值最大的特征向量作为投影方向。对多分类,投影矩阵由多个特征向量组成,通常取前 $K-1$ 个最大特征值对应的向量。Iris 是三分类,$K-1 = 2$,所以正好是一个二维投影。
2.3 为什么说 Fisher 本质上是“向低维压信息的压缩器”
Fisher 和 PCA 的最大区别在于,PCA 追求的是“保留方差最大的方向”,完全不看类别标签;Fisher 追求的是“让不同类别分离最明显的方向”,必须引入标签信息。换个角度说,PCA 是数据驱动的压缩,Fisher 是类别驱动的压缩。
举个例子,如果四个特征里有一个特征方差特别大,但这个特征和类别完全无关,PCA 会优先把它保留下来,Fisher 则会对它视而不见。Iris 数据里,花萼宽度虽然有一定方差,却不像花瓣长度那样能高效区分杂色鸢尾和维吉尼亚鸢尾,所以在 Fisher 的排序里,它的贡献会相对靠后。这个本质区别,是我觉得做这个项目时最应该体会的一点,也是从“会调包”到“理解算法”的分水岭。
3. 基于 Python 的完整实现:从零手推 Fisher 判别
3.1 环境准备与库选择
我这次用的环境是 Python + NumPy + Matplotlib + seaborn,外加 scikit-learn 做对照验证。前三者是主菜,scikit-learn 只用来和手写结果对比,防止自己算错。安装可以直接用:
pip install numpy matplotlib seaborn scikit-learn项目结构不强求复杂,临时文件夹里放一个 Jupyter Notebook 或者 .py 文件都行。我习惯拆成三段:数据加载与探索、Fisher 判别核心实现、可视化绘图,这样每一段的中间结果都能清晰打印出来。
3.2 数据加载与归一化处理
Iris 数据可以直接从 sklearn 里加载,返回的是 NumPy 数组,标签是整数 0、1、2,刚好对应三种鸢尾花。
import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from sklearn.datasets import load_iris iris = load_iris() X = iris.data # 形状 (150, 4) y = iris.target # 0, 1, 2 df = pd.DataFrame(X, columns=iris.feature_names) df['label'] = y print(df.describe())打印描述性统计后你会看到,花萼宽度的均值约是 3.05,花瓣宽度的均值约是 1.20,量纲不同,数值范围差距不小。如果直接拿原始数值算散度矩阵,数值较大的特征会对散度有更大的贡献,而实际上它们不一定对分类更关键。所以我在手推 Fisher 之前先做一次标准化,让四个特征落到相近的尺度:
X_mean = X.mean(axis=0) X_std = X.std(axis=0) X_norm = (X - X_mean) / X_std标准化本质是把特征的单位差异抹掉,让后续每个特征在判别方向上都有公平的竞争机会。如果你跳过这一步直接算,得到的投影方向会被花萼长度和花瓣长度这种绝对值较大的变量带走,效果不一定错,但解释起来会很被动。
3.3 手推 Fisher 判别:核心代码逐步拆解
下面这段代码是我在每个新环境里都要重新看一遍的,因为它不依赖任何高级包,把 Fisher 的前因后果都摊在明面上。
def fisher_lda(X, y, n_components=2): """ 手推多分类 Fisher 线性判别。 返回投影矩阵 W,以及中间过程的散度矩阵。 """ classes = np.unique(y) # 全局均值 mean_all = X.mean(axis=0) # 初始化散度矩阵 d = X.shape[1] Sw = np.zeros((d, d)) Sb = np.zeros((d, d)) for c in classes: X_c = X[y == c] mean_c = X_c.mean(axis=0) n_c = X_c.shape[0] # 类内散度:每个样本与所在类均值的偏差外积 centered = X_c - mean_c Sw += centered.T @ centered # 类间散度:类均值与全局均值的偏差外积,按样本数加权 diff = (mean_c - mean_all).reshape(-1, 1) Sb += n_c * (diff @ diff.T) # 求 S_W^{-1} S_B 的特征值与特征向量 eigvals, eigvecs = np.linalg.eigh(np.linalg.pinv(Sw) @ Sb) # 特征值从大到小排序 idx = np.argsort(eigvals)[::-1] eigvals = eigvals[idx] eigvecs = eigvecs[:, idx] W = eigvecs[:, :n_components] return W, eigvals, Sw, Sb这段代码有四个细节值得拿出来反复说。
第一,Sw的累加用的是每个样本减去自己类内均值后的叉积求和。你如果把代码里的centered打开打印,会发现它是一个(50, 4)的矩阵,最后centered.T @ centered出来的就是四个变量之间的协方差关系乘以自由度数。这比手写双重 for 循环效率高得多,而且思路更接近散度的数学定义。
第二,Sb的累加里有个容易忽略的权重 $n_k$。每个类别的均值与全局均值会有一个偏差向量,但不同类别的样本数量可能不一样,样本多的类别应该在类间散度里更有话语权。Iris 里三类样本数是相同的,都是 50,所以三类权重相同,但你在别的数据集上一定别忘了乘上n_c。
第三,这里用的是np.linalg.pinv(Sw)而非np.linalg.inv(Sw)。理论上 Sw 是可逆的,但在特征高度相关时,它的条件数可能很差,直接求逆会放大数值误差。用伪逆矩阵pinv更稳健,这也是我从几次数值炸掉之后养成的习惯。
第四,np.linalg.eigh对对称矩阵默认返回升序特征值,所以必须对索引做逆序处理。这里拿到的eigvecs每一列是一个判别方向,列与列之间相互正交,这是对称矩阵分解自带的性质。
跑完这段,你可以打印一下特征值:
W, eigvals, Sw, Sb = fisher_lda(X_norm, y) print("特征值:", eigvals) print("投影矩阵形状:", W.shape)核心输出会显示,第一个特征值远大于第二个,第二个远大于剩余。这说明四维信息中,绝大部分类别可分性都集中在前两个判别方向上,第三个方向可以放心丢弃。三个类别所以最多两个有效判别方向,这正好回到我前面提到的 $K-1$ 规律。
3.4 与 sklearn 结果对照验证
手推代码写完了,总担心哪里出错,怎么办?用 sklearn 里的 LDA 做个交叉验证是最直接的办法:
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis lda = LinearDiscriminantAnalysis(n_components=2) X_lda = lda.fit_transform(X_norm, y) print("手推投影方差对齐:", np.allclose(np.abs(X_norm @ W), np.abs(X_lda), atol=1e-8))注意这里取了绝对值,因为特征向量本身就带符号自由度,某个方向反转成负方向,分类效果完全一样,但直接比较数值会误报错误。如果出现不一致,优先检查Sw的定义——这是我最常犯错的地方,也是和 sklearn 结果核对时最能暴露问题的点。
3.5 在低维空间训练分类器并评估
Fisher 本身解决的是降维问题,降维之后你可以接任何分类器。最贴合原意的是在二维投影空间里再跑一次 LDA,也可以换成 KNN、朴素贝叶斯甚至逻辑回归。我选择直接用 LDA 作为分类器,因为它的判别思想在低维空间里几乎是天生一致的。
from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, confusion_matrix X_proj = X_norm @ W X_train, X_test, y_train, y_test = train_test_split( X_proj, y, test_size=0.3, stratify=y, random_state=42 ) clf = LinearDiscriminantAnalysis() clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print("测试集准确率:", accuracy_score(y_test, y_pred)) print("混淆矩阵:\n", confusion_matrix(y_test, y_pred))我在实际运行中,这个组合的测试集准确率能稳定达到 0.93 到 0.98 区间,偶尔会在重叠区的几朵花上犯一个小错误。这个“偶尔犯错”非常重要,如果你看到一个项目声称在 Iris 上拿到 100% 准确率,十有八九是把训练数据拿来测试了,或者干脆是从含重叠区的类别里偷看了标签。
4. 数据可视化实践:从原始四维到判别二维
4.1 用 pairplot 看清原始特征之间的纠缠
在动手投影之前,先画出所有特征两两组合的散点图矩阵,这一步能帮你建立“原始空间长什么样”的直觉。
df['label_name'] = df['label'].map({0: 'Setosa', 1: 'Versicolor', 2: 'Virginica'}) g = sns.pairplot(df, hue='label_name', vars=iris.feature_names, diag_kind='kde') plt.show()从这张图里,你能很直接地看到两个事实:Setosa 在几乎所有组合里都能被干净地分出来;Versicolor 和 Virginica 之间有一段明显的重叠。尤其是以花萼宽度作为纵轴时,两类几乎是上下穿插的,说明单个特征提供不了足够的判别信息。这张图的价值不在于好看,而在于提醒你,之后所有神奇的“分离”,都是组合特征后的功劳,不是某单个特征的固有属性。
4.2 把四维样本投影到 Fisher 方向后的二维分布
这是整个项目最直观的图,二维平面里画出投影后的三类样本,一般你会看到三团非常紧凑、彼此分离度很高的云。
plt.figure(figsize=(8, 6)) colors = ['#4C72B0', '#DD8452', '#55A868'] for c, color in zip(np.unique(y), colors): mask = y == c plt.scatter( X_proj[mask, 0], X_proj[mask, 1], c=color, label=[iris.target_names[c]], alpha=0.8, edgecolors='white', linewidths=0.5 ) plt.xlabel('判别方向 1') plt.ylabel('判别方向 2') plt.title('Iris 数据在 Fisher 判别方向上的二维投影') plt.legend() plt.grid(linestyle='--', alpha=0.4) plt.show()我特别喜欢这张图的一点,是它完美体现了 Fisher 这个方向组合的“秩序感”。原来的四维云在屏幕上没有办法看,但投影到两个判别方向上,你会直观地发现:类内是紧密的、类间是拉开的,这正是广义瑞利商最大化想要达到的效果。如果你同时把 PCA 投影也画出一张图,你会看到两者在二维分布上的差别,PCA 总是把方差最大的方向放在横轴,却不保证类别分离度,这张照妖镜似的对比图值得一试。
4.3 在二维平面上绘制分类决策边界
画完散点图,下一步是画分类边界。因为二维特征已经在低维空间里,你可以把决策区域铺成一个网格,对网格上每一个点调用分类器预测,再用等值线把预测区域染上颜色。
x_min, x_max = X_proj[:, 0].min() - 0.5, X_proj[:, 0].max() + 0.5 y_min, y_max = X_proj[:, 1].min() - 0.5, X_proj[:, 1].max() + 0.5 xx, yy = np.meshgrid( np.linspace(x_min, x_max, 300), np.linspace(y_min, y_max, 300) ) Z = clf.predict(np.c_[xx.ravel(), yy.ravel()]) Z = Z.reshape(xx.shape) plt.figure(figsize=(9, 7)) plt.contourf(xx, yy, Z, alpha=0.3, levels=3, cmap='Set2') plt.scatter(X_proj[:, 0], X_proj[:, 1], c=y, cmap='Set1', edgecolors='white', linewidths=0.6) plt.xlabel('判别方向 1') plt.ylabel('判别方向 2') plt.title('Fisher 投影后的 LDA 分类决策边界') plt.show()这张决策图会展示出清晰的两个分界点:一条几乎垂直于第一判别方向的线把 Setosa 隔开,另一条稍微倾斜的线把 Versicolor 与 Virginica 分开。我在第一次画出来的时候突然明白了,所谓“线性判别”,本质上就是在一个低维空间里找若干条直线,把不同类的高斯云团划开,而这些直线的几何位置,完全由各个类别的均值位置和协方差结构决定。
4.4 换个角度:三个判别方向的两两组合图
Fisher 在三分类问题上最多给出两个有效方向,但如果你想看三个方向之间的几何关系,可以把投影延伸成三个特征,直接画三维散点图。
from mpl_toolkits.mplot3d import Axes3D W3 = eigvecs[:, :3] X_proj3 = X_norm @ W3 fig = plt.figure(figsize=(10, 8)) ax = fig.add_subplot(111, projection='3d') for c, color in zip(np.unique(y), colors): mask = y == c ax.scatter(X_proj3[mask, 0], X_proj3[mask, 1], X_proj3[mask, 2], c=color, label=iris.target_names[c], alpha=0.8) ax.set_xlabel('判别方向 1') ax.set_ylabel('判别方向 2') ax.set_zlabel('判别方向 3') ax.legend() plt.show()虽然第三个判别方向携带的有效分类信息很少,但你可以在三维图里很清楚地看到:三类样本基本上落在一个近似平面上,第三个维度的变化更多反映的是类内随机噪声,而不是类别差异。这反过来验证了“取前两个方向就够了”的判断。如果你想拿这个图做汇报,建议让三维图旋转几个角度,挑一个三类相对分离的姿态截图,会比默认视角好看得多。
4.5 可视化里的几个细节经验
画图时有两处小坑值得提醒。第一,散点尽量加边缘色,我用的是白色描边,在样本重叠密集的区域,白边能显著提升每朵花之间的分界感,图形不会糊成一团。第二,网格点数量根据图幅调整,300 个网格点覆盖一般够用,更多的话会拖慢绘图速度,更少则决策边界会显得锯齿明显。透明度 alpha 设置在 0.6 到 0.8 最佳,太低显得发虚,太高则在重叠区遮住背景颜色。
5. 常见错误、调试经验与进阶思考
5.1 特征值出现虚数的原因和处理方式
手推 Fisher 时,如果你用的是np.linalg.eig而不是np.linalg.eigh,并且Sw没被正确处理,很有可能会得到带虚部的特征值。矩阵特征分解在数值上要求输入严格对称,Sw理论上是对称矩阵,但浮点累加可能导致轻微的不对称。解决方案是先强制对称化:
Sw = (Sw + Sw.T) / 2 Sb = (Sb + Sb.T) / 2然后再用eigh。这也解释了我为什么在前面反复强调优先使用eigh这个为对称矩阵设计的专用函数,它更稳,速度也更快。
5.2 为什么这个场景下不能直接用 PCA 替代 Fisher
如果划分目标是通过投影让类别分开,PCA 在数学上不会帮你看到任何与标签有关的信息。Iris 上 PCA 的第一主成分会把方差最大的方向保留,也就是花瓣长度和花瓣宽度共同主导的方向,这个方向确实也带有一定的类别信息,但它不会专门为了拉大 Versicolor 和 Virginica 的边界而调整方向。因此 PCA 投影结果通常表现为“Setosa 很轻松分出来,另外两类纠缠在一起”,而 Fisher 投影可以把后两类的重叠降到最低。
我建议你做一个对比实验,把 PCA 后的二维投影和 Fisher 后的二维投影放在同一张画布里,你会非常直观地理解“监督降维”和“无监督降维”的分野。这个对比实验也是这个项目最有价值的产物之一。
5.3 标准化在 Fisher 判别中的实际影响
如果跳过标准化直接跑,Fisher 的投影方向几乎会被花萼长度和花瓣长度这类大范围特征主导,花萼宽度的判别贡献会被稀释。你可能会得到一个依然可用的分类结果,但决策边界的几何解释会很凌乱。我在实验中用原始特征和标准化特征各跑了一遍,准确率差距不大,但投影图的“类别分离质感”差距却很明显。所以,无论什么时候,先可视化一下原始特征的量纲,再决定是否需要标准化。
5.4 交叉验证与数据集划分经验
Iris 只有 150 条样本,如果划分不当,极易产生过高的随机波动。我用train_test_split时固定了random_state=42,并用stratify=y保证三个类在测试集中都有相同占比。如果你在别的数据集上做类似实验,更稳妥的做法是多跑几组随机种子,把准确率的均值和方差都打印出来,而不是只汇报一次结果。
5.5 后续可以如何扩展
学会了 Iris 的 Fisher 判别,下一步有两个方向值得走。一个是换数据集,比如手写数字数据集或者葡萄酒数据集,前者类别更多,后者特征量纲差异很大,能逼你更完善地处理标准化和特征分解的细节。另一个方向是把 Fisher 换成 QDA,也就是二次判别分析,它会考虑各类协方差矩阵不一致的情况,投影出来的决策边界会变成曲线,分析过程比 LDA 更有讨论度。
我个人实际做下来的体会是,Fisher 线性判别在 Iris 上看似简单,但它把一个“四维不可见”的问题变成了一个“二维可感知”的问题,这是所有降维方法最迷人的地方。当你在可视化里看到三团云因为一个投影方向而彻底分开时,那种感觉比任何代码评分都更有成就感。最后再分享一个小技巧:在 Jupyter 里做这类分析时,把散度矩阵中间值保存下来,在决策边界图旁边打印一个小表格,汇报项目时很有说服力,因为它把每一步数学推导和最终的图连接起来了。