Matlab降维工具箱:PCA/t-SNE选型与交叉验证避坑
简介:Matlab数据降维工具箱面向科研人员、高校师生和算法开发者,适合高维数据特征压缩、可视化探索、分类聚类前处理以及多种降维方法横向对比。内容覆盖PCA、LDA、ICA、MDS等经典线性方法,也包含Isomap、LLE、Laplacian、LTSA、DiffusionMaps等流形学习方案,并延伸至核PCA、核LDA、SPCA、CCA、MVU与自编码器等,方法谱系较完整。压缩包共84个文件,约1011KB,以57个.m源码为主体,配合多平台mex二进制、C/C++及头文件、少量可执行程序和说明文本,兼顾直接调用与跨平台编译扩展。已有4285人学习,适合快速搭建降维实验基线、比较算法表现或按需改造源码的研究与工程场景。
1. 一张 200 多列的特征表:这套 Matlab 降维工具箱解决哪个环节
一张 200 多列的特征表丢进 Matlab,PCA 跑完前两个主成分只解释了三成多的方差,散点图糊成一团——这时候缺的不是再换一个算法,而是一套能把预处理、降维、验证串起来的 Matlab 降维工具。这套 Dimensionality Reduction 工具箱把线性方法(PCA、LDA、因子分析)与非线性嵌入(Isomap、LLE、t-SNE、UMAP)收在相近的调用习惯下,附示例脚本和小尺寸演示数据,装好就能跑通从特征表到二维图、再到下游分类器的完整链路。适合做信号与图像特征分析的研究生、要把高维指标压成可视化报表的算法工程师,也适合给课程设计找一份能改能调的实现。
2. 算法选型:PCA、LDA、Isomap、t-SNE 各自的适用边界
降维翻车,八成不是因为代码写错,而是因为一开始就把方法用错了场景。把 t-SNE 的二维图当成“可分性证据”去做特征筛选,把 PCA 用在一条卷起来的螺旋流形上,最后得出的结论必然站不住。这一章先把选型的判断依据讲清楚,再落到工具箱里各函数的调用约定上,后面动手才有方向。
2.1 先判断数据是不是躺在一张弯曲的薄片上
选型的第一步不是看算法列表,而是看数据的局部结构。我一般会抽 300~500 个样本,用原始特征的前三个主成分画一次三维散点,再对同一批样本算一下每列特征的相关系数矩阵。如果前几个主成分就能吃掉大部分方差、相关系数矩阵里有明显的块状结构,说明数据近似线性,PCA 或因子分析够用;如果三维图里出现明显的弯折、环状、卷曲,主成分解释率却上不去,那就说明数据落在一张非线性流形上,得换 Isomap、LLE 或 t-SNE 这类基于近邻图的方法。
第二件事是确认有没有标签。有标签且要做分类,LDA 是有监督投影里最省事的选择,它显式地最大化类间散度与类内散度的比值,投影后的维度上限是“类别数减一”,这一点必须提前算清楚,别拿 10 类数据去要 20 维 LDA 结果。没有标签就只用无监督方法,不要偷偷把标签喂进去调参数,那是另一种形式的泄漏。
第三件事是数据的量级。t-SNE 的复杂度大致随样本数呈平方增长,样本过万时在普通工作站上会很吃力,工具箱里那套 Barnes-Hut 近似实现能缓解一部分压力,但内存峰值依然要看近邻图。Isomap 和 LLE 要先构 kNN 图再求特征分解,样本数上万时稀疏矩阵的构造本身就会成为瓶颈。样本量大、只想要一个稳定的低维表示,PCA 依旧是性价比最高的起点。
2.2 工具箱统一的调用约定:输入矩阵、输出得分、附加结构体
这套工具箱在接口上做了一件很讨巧的事:所有方法都遵循“输入样本×特征矩阵,输出低维得分矩阵”的主约定,附加信息放在第二个返回值里。PCA 的第二个返回值是主成分系数与解释方差,Isomap 和 LLE 返回的是近邻图与残差,t-SNE 返回的是收敛信息。这样一来,第 3 章那条链路里替换算法只需要改一行调用,后面的出图和验证代码完全不用动。
调用前有两个约定必须记住。第一,输入必须是数值矩阵,table要先转成double,类别型、时间型列一律提前处理掉,工具箱不会替你猜。第二,绝大多数方法默认把每一列当成一个特征、每一行当成一个样本,如果你的数据是转置的(很多传感器数据导出就是这样),必须显式转置,否则降维结果看上去“能跑”,但每一个维度都在混样本,属于最难排查的一类错误。
注意:工具箱里的示例脚本用的是自带的小尺寸演示数据,直接换成自己的表之前,先确认列方向与样本数,别急着调参数。
2.3 四类方法的对照与选择顺序
把方法按“是否需要标签、是否线性、能否增量更新、可解释性”四个维度摆开,选型就变成了查表。下面这张表是我自己常用的决策顺序,从左往右逐项排除,剩下的就是候选。
| 方法 | 需要标签 | 线性 | 可解释性 | 典型用途 | 主要代价 |
|---|---|---|---|---|---|
| PCA | 否 | 是 | 高,载荷可回溯原始特征 | 去冗余、压缩、可视化 | 对非线性流形无能为力 |
| 因子分析 | 否 | 是 | 高,有潜变量含义 | 问卷、指标建模 | 需要指定因子数并做旋转 |
| LDA | 是 | 是 | 中,判别方向可解释 | 有监督分类前的投影 | 维度上限为类别数减一 |
| Isomap | 否 | 否 | 中,测地距离可解释 | 卷曲流形的展开 | 对近邻数敏感,图不连通会报错 |
| LLE | 否 | 否 | 低 | 局部结构保持 | 对新样本无自然映射 |
| t-SNE | 否 | 否 | 低,簇间距离无意义 | 高维特征的可视化检查 | 慢,参数敏感,不可当特征用 |
| UMAP | 否 | 否 | 低 | 可视化 + 粗略的降维特征 | 依赖近邻数与最小距离参数 |
一个常见的误用是把 t-SNE 的输出直接当特征送进分类器。t-SNE 只保留局部邻域概率结构,簇与簇之间的距离、簇内方差都没有意义,用它做特征,分类器学到的是一张随机布局图。要降维后接下游模型,优先 PCA、LDA 或 UMAP;要给人看,才用 t-SNE。
3. 跑通第一条链路:从原始特征表到可解释的二维图
这一章把整条链路走一遍:读表、清洗、标准化、PCA 定维、非线性嵌入出图、结果写回。每一步都给可抄的代码,代码后面说明为什么这么写、参数改了会怎样。跑完这一遍,你手上就有一份能替换成自己数据的骨架。
3.1 装载与清洗:把 ID 列摘出去
很多特征表的第一列是样本编号,第二列可能是采集时间,直接table2array会把字符串列变成NaN或者干脆报错。稳妥的写法是先声明导入选项,再把非数值列单独摘出来保存,后面出图时还要用它们标注点。
% 读入特征表,保留原始列名,避免中文列名被自动改写 opts = detectImportOptions('features_demo.csv'); opts.VariableNamingRule = 'preserve'; T = readtable('features_demo.csv', opts); % 第一列当样本 ID,其余列当特征;非数值列在这里就被挡掉了 sampleId = string(T{:, 1}); X = table2array(T(:, 2:end)); if ~isa(X, 'double') X = double(X); end % 缺失值处理:先统计再决定,别上来就填 0 missRate = mean(isnan(X), 1); fprintf('含缺失的列数:%d,最高缺失率 %.2f%%\n', sum(missRate > 0), 100*max(missRate)); X = fillmissing(X, 'constant', 0);detectImportOptions比csvread稳,它会推断每列类型;VariableNamingRule设成preserve是为了保住原始列名,后面看 PCA 载荷时要靠它对应回业务含义。缺失值那段先打印统计再填,是因为“整列八成都是空”这种列直接补 0 等于往数据里灌噪声,正确做法是整列删掉。填 0 只是为了让后面zscore不返回NaN,如果缺失率超过 5%,建议换成按列中位数填充,或者干脆用工具箱里带缺失处理的变体。
3.2 标准化与 PCA:用累计方差定维数
特征量纲不统一是 PCA 最常见的翻车点。一个以毫伏为单位的通道和一个以百分比为单位的通道放在一起,前者方差大几十倍,第一主成分几乎就是它自己。标准化之后再分解,各列权重才可比。
% 按列 z-score 标准化,同时保留 mu/sigma 供新样本复用 [Xn, mu, sigma] = zscore(X); % 已经中心化过,这里关掉 pca 的内部中心化,避免二次处理 [coeff, score, latent, tsq, explained] = pca(Xn, ... 'Algorithm', 'svd', 'Centered', false); cumVar = cumsum(explained); k = find(cumVar >= 95, 1, 'first'); fprintf('累计解释 95%% 方差需要 %d 个主成分(共 %d 维)\n', k, size(X, 2)); fprintf('前三个主成分解释率:%.2f%% / %.2f%% / %.2f%%\n', explained(1:3));zscore的三个返回值要一起留着:以后来了新样本,必须用训练集的mu和sigma变换,不能用新样本自己的统计量,否则训练和推理的口径不一致,这是第 5 章要展开的坑之一。Algorithm选svd在特征数远大于样本数时更稳,eig在维度低时更快,两者结果在数值上等价但符号可能相反,PCA 主成分的正负号本来就没有绝对意义,别因为两次跑出来图翻转了就以为代码有问题。
k的取法这里用 95% 阈值,是最省事的一种。实际项目里我一般会同时看碎石图的拐点,两者差得多的时候以拐点为准,因为方差阈值会被少数高方差噪声列带偏。如果后面要接分类器,更靠谱的做法是直接把k当超参数,用交叉验证选,这一点放到 4.1 讲。
3.3 非线性嵌入:困惑度与近邻数怎么给初值
PCA 出来的图如果仍然糊成一团,就该上非线性方法了。t-SNE 的困惑度可以理解为“每个点预期的有效邻居数”,经验初值取样本数的平方根再夹到 5~50 之间;近邻图方法(Isomap、LLE、UMAP)的 k 一般从 10~15 起,再按图的连通性调整。
rng(20240501); % 固定随机种子,保证同一份数据每次出图一致 % 先用 PCA 降到 30~50 维再喂 t-SNE,既降噪也提速 Y = tsne(score(:, 1:min(50, k)), ... 'NumDimensions', 2, ... 'Perplexity', 30, ... 'Exaggeration', 4, ... 'LearnRate', 500, ... 'Distance', 'euclidean', ... 'Standardize', false, ... 'Verbose', 1); % 近邻图方法:k 从 12 起,返回值里带残差,可以判断是否退化 [Yiso, ~, info] = isomap(Xn, 'NumNeighbors', 12, 'NumDimensions', 2); fprintf('Isomap 残差:%.4f\n', info.reconstructionError(end));先 PCA 再 t-SNE 是常规做法,把几百维降到 50 维,既能压掉大部分噪声维度,也能把 t-SNE 的耗时砍掉一大截。Perplexity小于 5 时结果会退化成局部小团,大于 50 时计算量陡增且结构被抹平,30 是个安全的起点。Exaggeration控制早期阶段的簇间斥力,值大一些簇分得更开,但太大会造出并不存在的空隙,4 到 12 之间比较常见。
Distance这一项容易被忽略:特征都是正值的计数型数据(比如词频、像素强度累计),用cosine往往比euclidean更能反映相似性;标准化后的连续特征就老实待着用欧氏距离。Standardize在已经做过 z-score 的情况下必须关掉,否则相当于标准化了两遍,各列权重会被再压一次。
Isomap 返回的info.reconstructionError是个有用的诊断量:它随维度增加应当单调下降,如果在某个维度之后基本不动,说明再加维度也榨不出结构了。另外isomap对图连通性很敏感,k 太小时会出现孤立点,函数会给出警告或者返回NaN,这时候把 k 往上加两三个再试。
3.4 出图与导出:把坐标写回表里
降维结果最终要么给人看,要么给下游用,两条路都得把坐标存成结构化数据,别只留一个scatter。我通常把低维坐标和原始 ID、标签拼成一张表导出,方便后面用别的工具复核。
% 拼成交付用的表:ID + 标签 + 二维坐标 + 前若干个主成分 outTbl = table(sampleId, Y(:,1), Y(:,2), ... 'VariableNames', {'SampleId', 'Dim1', 'Dim2'}); writetable(outTbl, 'embedding_tsne.csv'); % 出图:按标签着色,点大小随局部密度微调 figure('Color', 'w'); gscatter(Y(:,1), Y(:,2), label, [], '.', 12); xlabel('t-SNE 1'); ylabel('t-SNE 2'); title(sprintf('perplexity=30, k=%d', k)); exportgraphics(gcf, 'embedding_tsne.png', 'Resolution', 300);gscatter比手动循环scatter省事,它会自动分配颜色。exportgraphics比saveas清晰度高,投稿或者写报告都能直接用。导出表里额外保留前若干个主成分是有意义的:可视化靠 t-SNE,下游建模靠 PCA,两条线并行不冲突,也方便后面对照“图上看着分开”和“数值上确实可分”是不是一回事。
提示:图上的簇间距不要当相似度读。t-SNE 只保证邻域概率结构,两个簇在图上一左一右,不代表它们在原始空间里离得远。
4. 参数标定:维数、近邻数、困惑度和随机种子
参数乱给是降维结果的第二大杀手。同一份数据,k 取 5 和取 50 出来的图能讲出两个完全相反的结论。这一章把四个关键参数拆开讲:每个参数控制什么、初值怎么给、怎么用下游任务反过来验证。
4.1 目标维数的三种定法:方差阈值、平行分析、下游精度
方差阈值最直观,也最容易被噪声带偏。当数据里混着几个高方差的仪器漂移通道时,前两个主成分可能就是这俩通道,累计方差很快到 90%,但结构一点没提出来。这种情况要先做异常通道筛查,或者改用稳健的协方差估计。
平行分析是更严谨的一种:把原始矩阵的每一列各自独立打乱,做一次 PCA,得到一条“纯噪声下的解释方差曲线”,真实曲线的拐点要高于这条噪声曲线才保留。实现只需要十几行,跑一次几十秒,比拍脑袋定阈值强。
% 平行分析:打乱各列内部顺序,破坏列间相关性,保留边缘分布 nPerm = 20; noiseVar = zeros(size(Xn, 2), nPerm); for p = 1:nPerm Xp = zeros(size(Xn)); for j = 1:size(Xn, 2) Xp(:, j) = Xn(randperm(size(Xn,1)), j); end [~, ~, ~, ~, expP] = pca(Xp, 'Algorithm', 'svd', 'Centered', false); noiseVar(:, p) = expP; end noise95 = prctile(noiseVar, 95, 2); % 每个维度的噪声解释率上界 kParallel = find(explained(:) <= noise95, 1, 'first') - 1; fprintf('平行分析建议保留 %d 维\n', kParallel);这段代码的关键在“按列独立打乱”:列与列之间的相关性被破坏了,但每列自己的分布没变,所以噪声曲线反映的是“如果这些特征彼此无关,PCA 能提出来多少”。真实解释率低于噪声上界的那一刻,就说明再往后提的都是噪声。
第三种也是最贴合业务的:把维数当超参数,用交叉验证后的分类或回归精度来选。做法是让 k 在 5 到 100 之间按步长扫一遍,每个 k 都跑一次完整的“降维 + 训练 + 验证”,取精度最高且方差最小的那个。这种定法计算量最大,但它优化的是你真正关心的指标,尤其是后面要接模型的时候,前两种方法只当粗筛。
4.2 近邻图参数:k 太小碎片化,太大抹平结构
Isomap、LLE、UMAP 都依赖 kNN 图,k 决定了“局部”有多局部。k 太小,图变成一堆互不相连的小碎片,测地距离算不出来,LLE 的重构权重矩阵会病态;k 太大,邻域跨越了流形的弯折处,测地距离把不同分支上的点串成一条直线,展开后的结构全错。
实操里我一般这样定:先取 k = 10,画出图,看有没有明显的孤立点或者整片区域被压成一条线。有孤立团就往上加,每次加 5,直到图连通;如果加上去之后原本分开的两簇黏在一起,说明已经过头了,退回去取中间值。工具箱里的近邻图诊断函数能直接输出连通分量个数,比肉眼判断准。
注意:UMAP 的
n_neighbors和 t-SNE 的Perplexity不是一回事。前者直接控制近邻图规模,后者是概率分布的熵目标,两者虽然都在调“局部性”,但敏感区间和调法完全不同,别套用经验值。
4.3 用下游分类器给降维结果打分
降维好不好,最终看下游任务吃不吃得下。最省事的评估是:固定分类器(我一般用线性 SVM 或 KNN 做基线),换不同的降维方法和维数,比 5 折交叉验证的精度。之所以用简单分类器,是因为复杂模型会把降维的差异吃掉,你调了半天以为降维有用,其实模型自己在高维空间里也学到了。
| 参数 | 控制什么 | 初值 | 敏感区间 | 失控表现 |
|---|---|---|---|---|
| 目标维数 k | 保留信息量 | 累计方差 95% | 5~100 | 太大引入噪声,太小丢失判别信息 |
| 近邻数 k(图方法) | 局部邻域尺度 | 10~15 | 5~50 | 太小图碎片化,太大抹平流形 |
| Perplexity | 邻域概率熵 | 30 | 5~50 | 太小出小团,太大糊成一片 |
| Exaggeration | 早期簇间斥力 | 4 | 4~12 | 太大造出假空隙 |
| 随机种子 | 初始化 | 固定整数 | — | 不固定则图不可复现 |
评估时有一个细节要注意:降维和分类器必须在小折内重新拟合,不能在全量数据上先降维再切折。这一点在 6 章用一整段代码展开,这里先记住结论:只要降维看到了验证折的数据,精度就是虚高的。
4.4 复现性:种子、并行与浮点差异
t-SNE 和 UMAP 都是随机初始化,不固定种子,你每次截图都不一样,汇报的时候说不清是哪一版。固定rng是基本操作,但还有两个细节:一是并行循环里每个 worker 的随机流是独立的,parfor里调 t-SNE 需要给每次迭代单独设种子;二是不同 Matlab 版本、不同 BLAS 库的浮点累加顺序不同,同一颗种子在 A 机器和 B 机器上仍可能有微小差异,图会差一点点。
% parfor 里保证每个任务独立可复现 seeds = 20240501 + (0:nRuns-1); results = cell(nRuns, 1); parfor r = 1:nRuns rs = RandStream('Threefry', 'Seed', seeds(r)); RandStream.setGlobalStream(rs); results{r} = tsne(score(:, 1:k), 'NumDimensions', 2, ... 'Perplexity', 30, 'Verbose', 0); endThreefry是支持并行独立流的生成器,用它比默认的Mersenne Twister在parfor里更安全。多次运行不同种子还能干一件有用的事:把多次结果的邻域保持度(比如 KL 散度终值)统计一下,波动特别大的话说明参数落在了不稳定区间,这本身就是个信号,比单次跑出来的漂亮图可信得多。
5. 避坑与排查:五个把降维结果做废的细节
下面五条都是我在实际项目里真踩过或者帮人排查过的,每一条按“现象、原因、解决”写。看着琐碎,但每一条都能让一整天的结果白跑。
5.1 预处理顺序错位:两个最隐蔽的坑
现象一:模型在训练集上精度 0.95,换一批新数据掉到 0.6。原因是在全量数据上做了zscore和 PCA,然后才切分训练测试集,验证折的信息通过均值和主成分方向漏进了训练过程。这种做法在离线评估时看不出问题,因为评估本身也被污染了。解决办法是把mu、sigma、coeff都当成模型的一部分,只在训练折上拟合,验证折用训练折的参数做变换,具体写法见 6 章。任何时候你发现“离线指标好得不像话”,先查这条。
现象二:PCA 载荷图里第一主成分几乎等于某一个通道,其他通道权重接近 0。原因是没做标准化,那列的原始量纲远大于其他列。解决办法是分解前先zscore,同时把pca的Centered关掉,避免二次中心化。如果那列本身就该占主导(比如它确实是主信号),那另说,但大多数情况是量纲问题。
5.2 参数与评估口径:图好看不等于结论对
现象三:t-SNE 图上两类分得很开,换成线性 SVM 一测,精度只有 0.55。原因是把可视化当成了可分性证据。t-SNE 会放大局部密度差异,随机标签跑出来的图照样有漂亮的簇。解决办法是任何“看起来分开了”的结论都必须用下游分类器的交叉验证兜底,图只用来发现异常样本和检查数据质量。
现象四:换个随机种子重跑,原本清晰的三簇变成了两簇。原因是Perplexity落在了不稳定区间,或者样本里有一批近重复点。解决办法是把 perplexity 在 10~50 之间扫一遍,看簇结构在多大范围内保持稳定,只在稳定区间里取参数;近重复点先做去重或加微小扰动。一个只在特定种子下成立的结论,不要写进报告。
现象五:Isomap 跑出来一片NaN,或者图缩成一个点。原因是 k 太小导致近邻图不连通,测地距离矩阵里出现无穷大,特征分解自然崩。解决办法是逐步加大NumNeighbors直到连通分量为 1,工具箱的近邻图诊断能直接给出这个数字。如果加到很大才连通,说明样本本身在空间里分成了几个相距很远的团,这时候该先怀疑是不是混进了不同来源的数据。
5.3 内存与性能:样本量上来之后的现实问题
现象六:五万条样本跑 t-SNE,内存直接爆掉。原因是完整的相似度矩阵是 n×n 的,五万样本就是 2.5e9 个元素,双精度下 20GB 起步。解决办法有三条:先用 PCA 降到 50 维以内,减少距离计算开销;打开近似算法选项,把精确的近邻搜索换成近似搜索;再不行就分层抽样,先在一万条上把参数定下来,再对全量做一次固定参数的嵌入。别指望在小样本上定好的参数,直接搬到全量上还完全一致,样本规模变了,合适的 perplexity 也会变。
一言以蔽之,降维这条链路上,报错信息从来都指向代码,真正的问题几乎都藏在数据形态和评估流程里。看到异常先回头看数据,比盯着函数签名强。
6. 进阶:把降维塞进交叉验证,堵住标签泄漏
前面反复提到“降维必须在折内做”,这一段给一个能直接抄的完整写法。核心思路是把标准化、PCA、分类器打包成一条“在训练折上拟合、在验证折上只用 transform”的管道,每一折都从头来一遍。
cvp = cvpartition(label, 'KFold', 5, 'Stratify', true); acc = zeros(cvp.NumTestSets, 1); for i = 1:cvp.NumTestSets tr = training(cvp, i); te = test(cvp, i); % 只用训练折估计 mu / sigma / 主成分方向 [Xtr, mu, sigma] = zscore(X(tr, :)); [coeff, scoreTr, ~, ~, explained] = pca(Xtr, ... 'Algorithm', 'svd', 'Centered', false); k = find(cumsum(explained) >= 95, 1, 'first'); Ztr = scoreTr(:, 1:k); % 验证折用训练折的参数变换,绝不重新拟合 Zte = ((X(te, :) - mu) ./ sigma) * coeff(:, 1:k); mdl = fitcecoc(Ztr, label(tr), 'Learners', 'linear'); pred = predict(mdl, Zte); acc(i) = mean(pred == label(te)); end fprintf('5 折精度 %.3f ± %.3f\n', mean(acc), std(acc));这段代码里有两处必须照抄而不能“优化”。第一,Zte的计算用的是训练折的mu、sigma和coeff,如果偷懒写成对X(te,:)再跑一次zscore和pca,验证折的统计量就参与了变换,精度会虚高几个百分点,而且这个偏差在小数据集上特别明显。第二,fitcecoc在每个折里重新训练,不能在外面训好一个模型循环里只换数据。这两条加起来,才叫真正的“折内降维”。
如果要把降维方法当超参数一起搜,用fitcecoc外面套一层bayesopt或者简单的网格扫,把k和近邻数放进搜索空间。注意parfor和cvpartition一起用时,随机流要单独设,否则各折的划分虽然固定,降维内部的随机初始化还是会飘。
还有一个容易被忽略的点:LDA 这类有监督降维,标签必须只来自训练折。有人图省事在全量数据上跑一次 LDA 再切折,这比 PCA 泄漏更严重,因为判别方向直接把测试折的标签信息编码进去了,精度能虚高十几个点,而且离线怎么调都“很好”,一上线就原形毕露。
我现在的习惯是,只要流程里出现zscore、pca、tsne这些词,就先写一个空的折循环,确认所有拟合动作都在tr的索引里,再往里填模型。有次赶进度跳过了这一步,回头查一个“训练集 0.98、线上 0.61”的模型,翻了半天代码才发现标准化写在了切折之前,血泪经验,从此再没省过这五行循环。希望帮到你。
本文还有配套的精品资源,点击获取