简介:LDA.zip是一份面向机器学习初学者与数据挖掘实验者的线性判别分析(LDA)实战资源,围绕西瓜数据集3.0版本,演示如何利用Python实现特征降维与二分类判别。资源包含10个文件,核心为两个Python脚本及配套CSV数据文件,另含编译缓存、项目配置与辅助文件,压缩包仅12KB,轻量易用。已有585人学习浏览,适合正在完成西瓜数据集分类作业、课程设计或想快速上手LDA的读者。通过运行脚本,可以直观看到数据预处理、训练集划分、LDA模型拟合、预测及分类报告输出等完整流程,还能参考脚本中的注释与自定义函数设计,理解类内散度、类间散度与投影方向的关系,快速迁移应用到其他线性判别任务中。
1. LDA.zip 这个压缩包里到底装了什么:西瓜数据集上的线性判别分析
LDA.zip 这个压缩包名把 lda、线性判别分析、西瓜 Python 和西瓜数据集凑在一起,对应的正是机器学习入门里最经典的一个实验:用经典的西瓜数据集(17 条记录,取密度和含糖率两个连续特征),实现线性判别分析(LDA),判断西瓜是好瓜还是坏瓜。这个组合几乎所有学分类的人都手写过,样本量小到每一步中间结果都能肉眼核对,特别适合把 LDA 从黑匣子变成白盒。但真做一遍的人会发现一堆课本不讲的坑:协方差矩阵求逆报错、好瓜坏瓜数量不均、17 条样本怎么划分都像在碰运气。这篇文章就把 LDA 的判别原理、Python 实现、参数设计和几处翻车点一次讲透,新手能照步骤跑通,老手能借机重新审视那些想当然的细节。
2. 线性判别分析的核心逻辑:从投影到最优分类方向
线性判别分析的目标用一句话概括:找一个投影方向 w,把样本都投影到这条直线上,让两类点在这条线上分得越开越好。这里的“判别”是监督的,它利用了每个样本的类别标签;这一点决定了它和 PCA 有本质区别,也是理解 LDA 所有后续代码的第一步。
2.1 什么叫“判别”:先想清楚 LDA 不是 PCA
PCA 找的是数据方差最大的方向,它不关心类别标签;LDA 找的是让两类“中心距离最大、类内散布最小”的方向。用西瓜数据说:好瓜和坏瓜在密度—含糖率这个二维平面上的分布,总方差最大的方向往往由两类整体散布主导,而不是由“两类之间”的差异主导。所以 PCA 的第一主成分方向未必能帮我们区分类别,LDA 则完全为分类服务。
拿到一份数据,先别急着跑代码,我一般会做一个小验证:把样本按类别标好颜色画散点图,目测两个类中心的连线方向;再用 NumPy 算一下 PCA 第一主成分方向,对比两者夹角。夹角越大,说明这个数据集的“最大方差方向”和“最大可分方向”差异越大,LDA 越值得用;夹角接近零,说明类别差异基本沿着数据的总体散布方向,用 PCA 降维也能凑合,LDA 的优势就不明显。
这个小验证不需要任何机器学习库,只用中心化和特征分解就能完成,是判断“该不该用 LDA”的最快路径。它也能帮新手建立直觉:LDA 不追求保留数据的主要信息,只追求保留分类需要的信息,这是它和 PCA 在目标上的分水岭。
2.2 类内散度与类间散度:LDA 的数学骨架
两个散度矩阵构成 LDA 的核心。类内散度矩阵 Sw = Σ_k Σ_{x∈C_k} (x - μ_k)(x - μ_k)^T,衡量每个类别内部的样本离自己中心的远近;类间散度矩阵 Sb = (μ_0 - μ_1)(μ_0 - μ_1)^T,衡量两个类中心的差异。注意 Sb 是用外积定义的,两个向量的外积结果是一个秩至多为 1 的矩阵,这决定了二分类 LDA 的投影方向只有一个。
LDA 要最大化目标函数 J(w) = (w^T Sb w) / (w^T Sw w)。分子是两类中心投影到 w 上后的距离平方,分母是两类样本投影后的合并方差。这个比值是广义瑞利商,最大化它的 w 有闭式解:w = Sw^{-1}(μ_0 - μ_1)。这个公式是整份 LDA.zip 项目里最值钱的一行,手写实现时也会落到这一行上。
import numpy as np # 用8条样本的迷你数据演示散度矩阵计算,方便核对每一步数字 X_demo = np.array([ [0.6, 0.1], # 好瓜 [0.7, 0.2], # 好瓜 [0.5, 0.3], # 好瓜 [0.8, 0.4], # 好瓜 [0.1, 0.5], # 坏瓜 [0.2, 0.4], # 坏瓜 [0.3, 0.6], # 坏瓜 [0.0, 0.5], # 坏瓜 ]) y_demo = np.array([1, 1, 1, 1, 0, 0, 0, 0]) # 按类别分组,计算类内散度矩阵 Sw Sw = np.zeros((2, 2)) class_means = [] for c in np.unique(y_demo): Xc = X_demo[y_demo == c] mean_c = Xc.mean(axis=0) class_means.append(mean_c) Xc_centered = Xc - mean_c Sw += Xc_centered.T @ Xc_centered # 每个类别内部做外积累加 # 类间散度矩阵 Sb 只用两个类的中心差决定 mean0, mean1 = class_means Sb = np.outer(mean0 - mean1, mean0 - mean1) # 闭式解:w = Sw^{-1}(μ0 - μ1),用 solve 代替显式求逆 w = np.linalg.solve(Sw, mean0 - mean1) print("类内散度矩阵 Sw:\n", Sw) print("类间散度矩阵 Sb:\n", Sb) print("投影方向 w:", w)逻辑说明:Sw 先按类别各自累加“去中心后的样本外积”,再把两类结果加起来;Sb 用 np.outer 做外积得到 2x2 矩阵。最后用 np.linalg.solve 求解线性方程组,数值上比先 np.linalg.inv(Sw) 再点乘更稳,避免显式求逆引入舍入误差。
参数说明:X_demo 必须是二维数组,行是样本、列是特征;y_demo 是 0/1 整数标签。换真实西瓜数据集时,把这两个变量替换成第 3 章准备的 X 和 y 即可。另外注意,投影方向 w 的长度不影响分类结果,因为 J(w) 对 w 的缩放是不变的,真正起作用的是 w 的方向;后续拿 sklearn 对拍时也是比方向而不是比数值。
2.3 多分类情况的 LDA:降维视角
多分类(C 类)时,类内散度 Sw 的定义不变,但类间散度要扩展成 Sb = Σ_{k=1}^{C} N_k (μ_k - μ)(μ_k - μ)^T,其中 N_k 是第 k 类样本数,μ 是全体样本均值中心。目标变成对 Sw^{-1}Sb 做特征值分解,取前 d 个最大特征值对应的特征向量作为投影方向,把数据降到一个 d 维子空间。
这里有两条实用结论。第一,LDA 最多有 C-1 个非零判别方向,C 分类最多降到 C-1 维;所以二分类的西瓜实验最后得到一条投影轴而不是一个投影平面。第二,类别中心算完之后,Sw 的规模只由特征维数和样本数决定,当某一类只有一条样本时,那个类内部的散度矩阵是零矩阵,Sw 必然奇异,后面的求逆步骤直接崩掉。这两条结论是后文避坑章节的伏笔,它们决定了 LDA 可以安全使用的边界。
LDA 降维在实际项目里常和可视化搭配:多分类先降到两维画散点图,看类别分布是否线性可分,再决定后面接什么分类器。这种先降维观察、再建模的习惯,比一上来就跑黑盒分类器更符合一线排查思路,也是这个压缩包项目最值得练的技能点。
3. 西瓜数据集的准备:把 17 条记录变成能喂给 LDA 的矩阵
3.1 数据结构:哪些特征能用,哪些要编码
西瓜数据集共有 17 条记录,每条记录包括编号、色泽、根蒂、敲声、纹理、脐部、触感、密度、含糖率、好瓜。按类型分,前几个是类别型特征,后两个是数值型连续特征,“好瓜”是二分类标签。LDA 的输入必须是数值矩阵,所以第一个决策是:哪些字段能直接送进去,哪些需要编码。
| 特征 | 类型 | 取值示例 | 是否直接进入 LDA |
|---|---|---|---|
| 色泽 | 类别 | 青绿 / 乌黑 / 浅白 | 否,需编码 |
| 根蒂 | 类别 | 蜷缩 / 稍蜷 / 硬挺 | 否,需编码 |
| 敲声 | 类别 | 浊响 / 沉闷 / 清脆 | 否,需编码 |
| 纹理 | 类别 | 清晰 / 稍糊 / 模糊 | 否,需编码 |
| 脐部 | 类别 | 凹陷 / 稍凹 / 平坦 | 否,需编码 |
| 触感 | 类别 | 硬滑 / 软粘 | 否,需编码 |
| 密度 | 数值 | 约 0.24~0.80 | 是 |
| 含糖率 | 数值 | 约 0.05~0.40 | 是 |
| 好瓜 | 标签 | 是 / 否 | 标签,转为 0/1 |
类别特征不能直接按 1、2、3 编码,因为那等于强加顺序关系,比如“青绿=1、乌黑=2、浅白=3”会暗示浅白比青绿“更大”,而实际上色泽没有大小之分。常见做法是只用密度和含糖率两个连续特征跑 LDA,这也是标题里“西瓜 python”这类项目最常见的实现方式。另一个可选做法是把类别特征独热编码后拼进特征矩阵,但独热产生的 0/1 指示变量和密度这种带尺度的连续特征混在一起后,LDA 的判别方向系数很难解释,且会制造大量零值让散度矩阵稀疏。
我的选择习惯是:教学和复现目的直接用连续特征。如果真的要把色泽、根蒂也放进来,就先独热编码,再对连续特征做标准化,最后在交叉验证里对比“加了离散特征后验证集准确率有没有提升”,没有提升就果断去掉。这样选特征有依据,不是拍脑袋。
3.2 读取数据与特征工程的最小代码
数据文件通常是 CSV 或 Excel 格式。读取后要做的只有三件事:把“好瓜”列转成 0/1 标签、抽取密度和含糖率两列组成特征矩阵、确认类别样本数。代码很短,但每一行背后都有值得注意的参数。
import pandas as pd import numpy as np # 读取 CSV 数据文件,文件需放在脚本同目录 df = pd.read_csv("watermelon.csv") # 把"好瓜"列转成 0/1 标签:是=1,否=0 df["label"] = (df["好瓜"] == "是").astype(int) # 只取两列连续特征进入 LDA,其余类别特征本例不使用 X = df[["密度", "含糖率"]].values y = df["label"].values print("样本数:", X.shape[0], "特征维度:", X.shape[1]) print("好瓜数量:", y.sum(), "坏瓜数量:", (y == 0).sum())逻辑说明:df["好瓜"] == "是" 得到一个布尔序列,astype(int) 把它转成 0/1;df[["密度", "含糖率"]] 用了双括号,表示按列名取多列得到 DataFrame,再 .values 转成 NumPy 二维数组,行是样本、列是特征。
参数说明:如果数据是 Excel 文件,把 pd.read_csv 换成 pd.read_excel("watermelon.xlsx"),需要先装好 openpyxl 库。列名必须和表头严格一致,中文列名最容易被复制出来的空格坑到,建议打印 df.columns 确认后再写列名。最后的类别数量输出是后续判断类别不平衡的重要信号:好瓜 10 条、坏瓜 7 条,比例还不算极端,但已经足够让中点阈值产生偏差,后面会专门讲。
3.3 划分训练集与测试集:小样本下的划分策略
17 条样本做单次划分,最大的问题是测试集太小。常见错法是直接 df.sample(frac=0.7) 乱切,不设随机种子也不分层,结果测试集可能只有一两条坏瓜,准确率波动极大。正确做法是分层划分,保证训练集和测试集里好瓜坏瓜比例基本一致。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, # 17 条里约 5 条做测试 stratify=y, # 分层:训练/测试里好瓜坏瓜比例一致 random_state=42 # 固定种子,保证实验可复现 ) print("训练集好瓜数:", y_train.sum(), "测试集好瓜数:", y_test.sum())逻辑说明:stratify=y 让划分时按标签比例抽样,避免小样本下某一类全部跑到测试集;random_state=42 只是让同一份代码在不同机器上拿到相同划分,它不会让结果变好,只会让结果可复现。
但单次划分仍是“抽一次彩票”。对于 17 条数据,我一般不用单次划分做最终评估,而是用留一法(Leave-One-Out)做整体验证:每次用 16 条训练、1 条测试,循环 17 次,把结果平均。LDA 训练一次是微秒级,17 次完全可接受,换来的是几乎不含随机性的评估结果。留一法的代码骨架先放在这里,第 4 章会用真实训练代码把它补全。
from sklearn.model_selection import LeaveOneOut loo = LeaveOneOut() for train_idx, test_idx in loo.split(X): X_train_loo = X[train_idx] y_train_loo = y[train_idx] X_test_loo = X[test_idx] y_test_loo = y[test_idx] # 下一章在这里补上:训练 LDA -> 预测 -> 记录结果逻辑说明:loo.split(X) 每次返回一组索引,train_idx 是 16 条训练样本的下标,test_idx 是那 1 条测试样本的下标。留一法对 17 条样本几乎不引入随机性,是判断“这个小模型到底有没有学到信号”的最可靠手段,比单次 train_test_split 靠谱得多。
4. 手写LDA训练与预测:核心代码和参数调优点
4.1 从散度矩阵到投影方向:训练过程
把第 2 章的散度矩阵计算封装成函数,就是手写 LDA 的训练过程。函数输出两个东西:投影方向 w 和两个类的中心向量。后者在预测阶段用来算阈值,所以不能省。
import numpy as np def lda_fit(X, y): """ 训练线性判别分析模型。 输入: X: 二维数组, 每行一个样本, 每列一个特征 y: 一维数组, 类别标签, 取值为 0 和 1 输出: w: 投影方向向量 class_means: 两个类的中心向量, 形状为 (2, 特征数) """ classes = np.unique(y) # 例如 [0, 1] class_means = [] Sw = np.zeros((X.shape[1], X.shape[1])) # 类内散度矩阵, 特征 x 特征 for c in classes: X_c = X[y == c] # 取出第 c 类的全部样本 mu_c = X_c.mean(axis=0) # 该类中心 class_means.append(mu_c) X_c_centered = X_c - mu_c # 去中心 Sw += X_c_centered.T @ X_c_centered # 类内散度累加 mean0, mean1 = class_means # 两类中心 Sb = np.outer(mean0 - mean1, mean0 - mean1) # 类间散度矩阵 w = np.linalg.solve(Sw, mean0 - mean1) # 闭式解 w = Sw^{-1}(μ0-μ1) return w, np.array(class_means)逻辑说明:Sw 是每个类内部去中心后,用 X_c_centered.T @ X_c_centered 做外积累加;Sb 由两个类中心的差做外积得到。最后 np.linalg.solve 直接解 Sw @ w = mean0 - mean1,相当于求逆但更稳。
参数说明:X 必须是二维浮点数组,不能是 DataFrame;如果 X 是 pandas 的 DataFrame,先 .values 转数组再传进来。y 的类别值不一定非要是 0/1,字符串类标签也可以,但第 4.2 节阈值代码按 0/1 写,建议提前统一转成 0/1。如果 np.linalg.solve 报奇异矩阵错误,说明 Sw 不满秩,原因和处理方案在第 5.1 节。
4.2 投影与分类器组合:阈值选在哪
训练完成后,把每个样本投影到 w 上,得到一维标量,问题就变成了在一维直线上找切分点。最常见的阈值是两类投影中心的算术平均,代码里先演示这个最直观的做法,它的缺陷在第 5.3 节再展开。
from sklearn.model_selection import train_test_split # 继续使用第 3 章的 X, y 做一次示例划分 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 ) # 训练 LDA w, class_means = lda_fit(X_train, y_train) mean0, mean1 = class_means # 投影到一维 proj_train = X_train @ w proj_test = X_test @ w # 训练集两类投影中心,取平均作为阈值 proj_center0 = w @ mean0 proj_center1 = w @ mean1 threshold = (proj_center0 + proj_center1) / 2 # 分类结果 y_pred = (proj_test > threshold).astype(int) acc = (y_pred == y_test).mean() print("测试集准确率:", acc) print("投影中心0: %.4f 投影中心1: %.4f 阈值: %.4f" % (proj_center0, proj_center1, threshold))逻辑说明:mean0 和 mean1 是训练阶段算出的类中心,在预测阶段它们只是常数向量。w @ mean0 是第 0 类中心在投影方向上的位置,阈值取两个位置的中间点。new样本的投影值大于阈值判为第 1 类,否则判为第 0 类;这里的 0/1 方向和 lda_fit 里 np.unique 的排序一致。
参数说明:这个阈值取法等权对待两个类,隐含假设两类先验概率相等。好瓜 10 条、坏瓜 7 条时偏差不大还能用,但如果两类数量悬殊,要继续往下看第 5.3 节调整阈值。另外,如果 w[0] 正好是 0(比如密度特征完全不参与判别),用 w @ mean0 计算不会出问题,但第 4.3 节对拍时做归一化要用 w[1] 当基准。
4.3 与标准库结果对拍:验证自己的实现
手写实现最怕方向向量算错而不自知。验证标准做法是拿 sklearn 的 LinearDiscriminantAnalysis 跑同一份数据,比较两边的判别方向。注意要比方向而不是比数值,因为 w 和 -w 对应同一条决策边界。
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis clf = LinearDiscriminantAnalysis(solver="svd") clf.fit(X_train, y_train) # 各自归一化到第一个分量,消除缩放差异 w_norm = w / w[0] sk_norm = clf.coef_[0] / clf.coef_[0][0] print("手写方向:", w_norm) print("sklearn 方向:", sk_norm)逻辑说明:sklearn 的 clf.coef_[0] 是它学到的判别方向系数,solver="svd" 让库内部用奇异值分解处理散度矩阵,不显式求逆。两边方向可能完全反号,这是正常的,因为 w 和 -w 等价。正常误差应该在几个百分点以内,如果差得离谱,优先回头检查 Sb 里 mean0 - mean1 的减数是不是写反了,或者 np.unique 的类别顺序是否符合预期。
参数说明:solver="svd" 适合小样本和特征数较多的场景,也是 sklearn 的默认改进点;如果数据集很大,solver="lsqr" 配合 shrinkage 更灵活。对拍时不要比较准确率——两边分类结果本来就该一致,直接比较 w 方向能定位到具体哪一步算错,省得在预测代码里瞎猜。
5. LDA实战避坑:小数据集、奇异矩阵与类别不平衡
这一章是血泪经验。LDA.zip 这个标题看起来简单,实际跑起来问题一个接一个,而且很多问题在课本例子上根本不会出现。下面五条是我在这个项目上反复踩过的坑,每条按“现象 → 原因 → 解决”讲清。
注意:小数据集上的 LDA 最害人的地方是“训练集准确率好看”,任何验证手段都必须和训练过程分离,否则你优化的是噪声而不是模型。
5.1 协方差矩阵求逆报错:奇异矩阵的三种解法
现象:np.linalg.inv(Sw) 报 “LinAlgError: Singular matrix”,或者 np.linalg.solve(Sw, mean0 - mean1) 报 “Matrix is singular”。
原因:Sw 不满秩。常见来源有三个:一是特征维度大于样本数,比如特征选择后剩 50 维但每类只有 20 条样本;二是两个特征几乎线性相关,典型例子是同时保留“含糖率”和“含糖率*2”这类冗余列;三是某一类样本只有一条,那个类内部的散度矩阵是零矩阵,Sw 整体退化。
解决:按顺序先查相关矩阵,删除相关性超过 0.98 的冗余列;如果还不满秩,给 Sw 加一个小对角矩阵 λI,λ 从 1e-4 起步逐步增大,这是最常用的正则化手段;最后实在不行再用 np.linalg.pinv 求伪逆兜底。在 sklearn 里直接设置 solver="lsqr", shrinkage="auto" 让库自动估计收缩量更省事,但手写实现时最好把 λ 暴露成函数参数,方便对比不同取值的效果。
注意:不要直接 Sw_inv = np.linalg.inv(Sw) 一把梭。先用 np.linalg.matrix_rank(Sw) 检查秩,如果秩小于特征维度,先处理数据再求逆。
5.2 标准化该不该做:尺度、条件数与正则化
现象:同一份数据,先 StandardScaler 再 LDA,结果和直接用原数据跑不一样,有时候更准,有时候反而变差,看起来像玄学。
原因:理论上 LDA 对特征的可逆线性变换是等变的,标准化不应该改变分类边界。但数值实现不跟理论完全同步:两个特征尺度差到三个数量级以上时,Sw 的条件数会很大,np.linalg.solve 的舍入误差被放大。更关键的是,一旦给 Sw 加正则化项 λI,λ 对每个维度加的量是相同的,对小尺度特征的实际惩罚远大于大尺度特征,标准化在这里直接改变了正则化的语义。
解决:我的习惯是先算 np.linalg.cond(Sw) 看条件数,超过 1e12 就先标准化再进 LDA。只要涉及 shrinkage 正则化,一律先做 z-score 标准化,再调 λ,否则调出来的最优 λ 在不同尺度下毫无可比性。这个顺序是踩过坑才固定的:先标准化再调参,比先调参再标准化稳定得多。
5.3 好瓜多坏瓜少:类别不平衡让阈值失效
现象:阈值取在两个投影中心的中点,坏瓜几乎全部被预测成好瓜,分错的集中在少数类。
原因:中点阈值隐含两类先验概率相等的假设。西瓜数据好瓜 10 条、坏瓜 7 条,比例不算极端,但当某一类样本数只有另一类的五分之一时,贝叶斯最优分割点会明显向多数类方向移动,中点不在最优位置。
解决:简单修正法,按先验加权设阈值 threshold = (proj_center0 * n1 + proj_center1 * n0) / (n0 + n1),其中 n0、n1 是两类样本数。更稳的做法是画出两类投影的直方图,在重叠区域选一个能让少数类召回率提高的切点,或者用 ROC 曲线找约登指数最大的点。如果调整阈值后少数类还是救不回来,先怀疑特征本身可分性不足,而不是继续调阈值。
5.4 特征多于样本:LDA在高维小样本上为什么容易翻车
现象:特征维度上百,每类样本只有二三十条,训练集准确率 100%,测试集准确率只剩五成,看起来像完美拟合噪声。
原因:Sw 在高维小样本下几乎必然奇异,解被伪逆或正则项主导,w 的每个分量都被训练集噪声污染;同时高维下“两类协方差相同”这个 LDA 前提假设更难成立,散度矩阵容易被极值样本带偏。
解决:先做维度控制,把特征压缩到样本数的五分之一以下再进 LDA,常见方案是先 PCA 到 20~30 维再做 LDA;或者启用收缩估计,让 shrinkage 从 0 到 1 扫一遍;再不行就换线性 SVM,它对高维小样本的鲁棒性通常比 LDA 好。高维小样本没有银弹,重点是别把 LDA 当黑匣子,直接往里面灌原始高维特征。
5.5 用留一法替代简单划分:17条样本的评估玄学
现象:用 train_test_split(test_size=0.3) 反复跑,每次准确率在四成到九成之间跳来跳去,报告的数字完全取决于 random_state 选了什么。
原因:17 条样本里测试集只有 5 条左右,任何一条样本改变归类,准确率就跳 20 个百分点;一次划分根本测不出模型的真实能力,只能测出“这次划得好不好”。
解决:改用留一法,17 次循环训练,把 17 条样本轮流当一次测试样本,最终的平均准确率方差小得多。代码骨架在 3.3 节已经给出,补上训练和预测就是完整评估。如果数据样本上万,留一法太慢,就改用重复分层 K 折:K=5 且重复 10 次取平均。评估时还要看混淆矩阵,不要只看准确率——小样本下准确率很容易被多数类掩盖,坏瓜全错但准确率照样八九成的情况真的会发生。
from sklearn.model_selection import LeaveOneOut from sklearn.metrics import accuracy_score preds, truths = [], [] for train_idx, test_idx in loo.split(X): w, means = lda_fit(X[train_idx], y[train_idx]) thr = 0.5 * (w @ means[0] + w @ means[1]) # 中点阈值 pred = (X[test_idx] @ w > thr).astype(int) preds.append(pred[0]) truths.append(y[test_idx][0]) print("留一法准确率:", accuracy_score(truths, preds))逻辑说明:每次循环都用 16 条样本重新训练一个 LDA,然后用剩下那 1 条测试。means[0]、means[1] 是 lda_fit 返回的两类中心,thr 是两类投影中心的中点。pred 是包含一个元素的数组,取 pred[0] 存下来。17 次全部结束后,用 accuracy_score 汇总。
参数说明:这段代码直接复用了第 4.1 节的 lda_fit,所以它和单次训练共用同一份实现。如果用了不平衡修正阈值,把 thr 那行替换成按先验加权的公式即可。留一法结果比单次划分稳定得多,是 17 条样本量下最值得信任的评估方式。
6. 把LDA用出效果:投影可视化、交叉验证与方向向量的解释
6.1 用特征值占比决定保留几个判别方向
LDA 在多分类场景下先求 Sw^{-1}Sb 的特征值分解,特征值大小表示每个判别方向能解释的类间差异占比。西瓜数据是二分类,Sb 的秩是 1,只会有一个非零特征值,占比必然是 100%,所以这个技巧在这里更多是验证性质;换成三分类以上时,它可以用来决定“降维降到几维才够用”。
eig_matrix = np.linalg.inv(Sw) @ Sb eigvals, eigvecs = np.linalg.eig(eig_matrix) eigvals = np.real(eigvals) # 数值误差可能产生虚部,取实部 idx = np.argsort(eigvals)[::-1] # 按特征值降序排列 eigvals = eigvals[idx] ratio = eigvals / eigvals.sum() print("特征值:", eigvals, "占比:", ratio)逻辑说明:np.linalg.eig 返回的特征值顺序不保证,必须用 argsort 排序后再算占比。二分类场景只有一个非零特征值,占比数组是 [1.0],说明所有类间信息都在这一个方向上;三分类以上如果前两个占比超过 85%,就值得投影到两维画图而不是硬塞三维。
6.2 投影分布与决策边界:一张图看懂分类质量
准确率只是数字,投影后的分布图能把 LDA 的“好不好看”直接暴露出来。把全体样本投影到 w 上,分别画好瓜和坏瓜的直方图,再加一条阈值线,一眼就能看清两类重叠程度。
import matplotlib.pyplot as plt proj_all = X @ w plt.hist(proj_all[y == 0], bins=5, alpha=0.6, label="坏瓜") plt.hist(proj_all[y == 1], bins=5, alpha=0.6, label="好瓜") plt.axvline(threshold, color="red", linestyle="--", label="阈值") plt.xlabel("投影值") plt.ylabel("样本数") plt.legend() plt.show()逻辑说明:threshold 来自第 4.2 节的计算结果,这里直接用。两张直方图的重叠区域越小,说明 LDA 在这个数据集上分得越干净;阈值线如果明显偏向某一侧,就是在提醒你类别不平衡需要修正阈值。这张图比准确率更能说明模型质量,也方便在汇报时把结论讲给不懂代码的人。
6.3 用交叉验证选正则化参数:别让shrinkage成为玄学
给 Sw 加正则化 λ 时,λ 不是一个可以凭感觉拍的值。对小样本,把留一法包在参数搜索外面,逐个试 λ,取留一法平均准确率最高的值。这是把参数选择从“玄学”变成“可复现实验”的关键一步。
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis from sklearn.model_selection import LeaveOneOut, cross_val_score best_acc, best_shrink = 0, 0 for shrink in np.logspace(-3, 0, 10): clf = LinearDiscriminantAnalysis(solver="lsqr", shrinkage=shrink) scores = cross_val_score(clf, X, y, cv=LeaveOneOut()) if scores.mean() > best_acc: best_acc = scores.mean() best_shrink = shrink print("最优 shrinkage:", best_shrink, "留一法准确率:", best_acc)逻辑说明:cross_val_score 会自动完成划分、训练、评估,cv=LeaveOneOut() 指定内部用留一法;外层循环在 0.001 到 1 之间按对数均匀取 10 个候选值。只有 solver="lsqr" 支持 shrinkage 参数,用 solver="svd" 时传 shrinkage 会直接报错。
参数说明:np.logspace(-3, 0, 10) 表示从 10^-3 到 10^0 取 10 个点,覆盖从几乎不加正则到强正则的范围。如果想更细,可以把网格加密到 20 个点;但 17 条样本下网格过密意义不大,留一法本身已经足够稳定,差一个位数的 shrinkage 对结果影响通常有限。
我在重做这个 LDA 小项目时养成了一个固定顺序:先跑通手写版本并和 sklearn 对拍,确认方向一致;再画投影直方图,看阈值是否合理;最后才谈准确率,而且只用留一法做最终评估。这个顺序让我在换到其他数据集时也能最快定位问题——是先怀疑数据,再怀疑实现,而不是反过来对着准确率瞎猜。希望帮到你。
本文还有配套的精品资源,点击获取