做这个实验项目之前,我对数据降维的认知差不多就停留在"把多维数据压成二维好画图"这个层面。真正动手把PCA、LDA、t-SNE和自编码器串到一起做完整套流程之后,才发现降维解决的是高维数据带来的计算压力、过拟合风险和信息冗余问题。这篇文章把整个实验项目的完成过程整理出来:从手写PCA验证原理,到有监督的LDA对比,再到t-SNE可视化,最后用自编码器做深度特征压缩。每个方法都配套可复现的代码,也会讲我实测中踩过的坑和实验报告里最容易被挑出来的问题。适合正在做类似实验项目、或者工作中遇到高维数据不知道从哪下手的开发者参考。
1. 高维数据低维投影:这个实验项目究竟在考什么
1.1 维度灾难带来的三个实际问题
先说一个很直观的例子。假设你手里有一批图片,每张是28×28像素,把像素拉平就是一个784维的特征向量。这个维度看起来不算夸张,但到了计算环节问题就出来了:
第一是计算开销。很多算法的复杂度随特征维度非线性上升,矩阵运算、距离计算、树模型的节点划分都会明显变慢。我试过在几千维的特征上直接跑近邻搜索,训练一次等结果的时间够写完一份实验报告。
第二是过拟合风险。维度越高,样本在高维空间里越稀疏。你想象一个单位立方体,维度从2维增加到10维,想在这个空间里均匀采样去估计某个区域的密度,需要的样本数量是指数级增长的。真实项目里样本量有限,特征维度一旦上去,模型很容易把噪声也当成规律学进去。
第三是可视化几乎失效。人类能看懂的图形最多三维,但高维数据在三维空间里的投影往往是一团看不出结构的点。做实验的时候如果连数据长什么样都不知道,后面调参、选模型全都像盲人摸象。
这三件事合在一起,就是数据降维要解决的核心问题:在损失尽量少信息的前提下,把高维数据映射到低维空间,让后续训练更快、泛化更好、分布也肉眼可见。
1.2 实验项目的四个核心任务
降维方法很多,实验项目通常不会只让你调一个库,而是要求从原理到实现走一个完整闭环。我这次的项目归纳下来是四个任务:
- 手写主成分分析(PCA),用代码验证一遍协方差矩阵和特征值分解的关系;
- 用线性判别分析(LDA)做有监督降维,和PCA对比,搞清楚"有标签"和"无标签"的区别;
- 用t-SNE做非线性降维,重点在可视化;
- 用深度学习中的自编码器(Autoencoder)做特征压缩,和线性方法对比效果。
实验数据集方面,为了方便复现,我选的是公开的手写数字图片集,每张8×8像素,拉平后是64维,总共10个类别。这个数据集足够小,任何机器都能跑,而且类别结构清晰,降维后的可视化效果一眼能看出好坏。如果你的实验要求用更大尺寸的图片,原理完全一样,把64换成784就能跑通。
这里要说清楚一个概念:降维有特征选择和特征抽取两条路线。特征选择是从原始特征里挑出一部分,保留原名和含义;特征抽取是构造新特征,让新特征的维度更小但信息更集中。本文提到的PCA、LDA、t-SNE、自编码器都属于特征抽取,这也是实验项目重点训练的能力。
2. 手写PCA:把协方差矩阵和特征值的关系彻底搞明白
2.1 为什么建议先手写一遍PCA而不是直接调库
现在的机器学习框架一行代码就能完成PCA降维,很多同学在做实验的时候直接调包,跑出一个二维散点图就完事。这样做可以,但实验项目真正想要的是让你理解PCA内部发生了什么。
原因在于,几乎任何关于降维的提问都会涉及这几个问题:为什么要标准化?协方差矩阵的意义是什么?特征值大小代表什么?为什么取最大的几个特征值对应的特征向量?这些问题,不手写一遍是很难真正回答出来的。
另外,手写一遍PCA之后再去看框架文档,你会发现自己能看懂里面参数的含义了,比如n_components到底是按数量截断还是按方差解释率截断,svd_solver的选择影响了什么。调库本身没有错,但调库之前最好知道库做了什么。
2.2 从零实现PCA的完整代码与逐行拆解
下面这份代码我建议你直接跑一遍,然后一行一行地看注释。它做的事情和sklearn里的PCA完全一样,只是把中间步骤显式暴露出来。
import numpy as np from sklearn.datasets import load_digits from sklearn.preprocessing import StandardScaler # 1. 加载数据。这里用手写数字集,64维,10个类别 digits = load_digits() X = digits.data y = digits.target print("原始特征维度: ", X.shape) # (样本数, 64) # 2. 标准化:让每个特征的均值为0,方差为1 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) # 3. 计算协方差矩阵 cov_matrix = np.cov(X_scaled.T) # 4. 特征值分解 eigen_values, eigen_vectors = np.linalg.eigh(cov_matrix) # 5. np.linalg.eigh 返回的特征值是按升序排列的,所以要反转 idx = np.argsort(eigen_values)[::-1] eigen_values = eigen_values[idx] eigen_vectors = eigen_vectors[:, idx] # 6. 取前k个特征向量,构成投影矩阵 k = 2 W = eigen_vectors[:, :k] # 7. 原始数据投影到低维空间 X_pca_manual = X_scaled.dot(W) print("手写PCA降维后: ", X_pca_manual.shape)拆解一下关键步骤。标准化的目的是消除量纲影响,PCA找的是方差最大的方向,如果某个特征本身数值范围大,比如像素值0到255,另一个特征范围只有0到1,PCA会优先照顾数值大的特征,但这不一定代表它真的更重要。标准化之后每个特征贡献的方差才是公平的。
协方差矩阵是核心。对角线上是每个特征的方差,非对角线上是两个特征的协方差。PCA要找的就是这样一个方向:数据投影到这个方向后方差最大。数学上可以证明,这个方向就是协方差矩阵最大特征值对应的特征向量。所以整个PCA的本质就是一次特征值分解,把协方差矩阵的结构拆开来看。
有一个细节容易踩坑:np.linalg.eigh专门用于对称矩阵,它返回的特征值默认从小到大排列。如果不做argsort()[::-1]这一步,你取到的"前两个特征向量"其实是信息量最少的两个方向,画出来的图会完全看不出类别结构。我第一次写的时候就忘了排序,投影结果一团糟,排查了半天才发现是排序问题。
2.3 调用sklearn做对比并确定保留维度
手写版本能跑通之后,再用官方实现验证一下结果是否一致。sklearn.decomposition.PCA默认使用SVD(奇异值分解)来求解,数值稳定性比直接做特征值分解更好,在大维度数据上速度也更快。
from sklearn.decomposition import PCA # 指定保留2个主成分 pca = PCA(n_components=2) X_pca_sklearn = pca.fit_transform(X_scaled) # 比较手写与sklearn结果是否一致(忽略正负号,因为特征向量方向可以相反) print("手写结果与sklearn结果差异: ", np.abs(X_pca_manual - X_pca_sklearn).max())运行之后你会发现两者差异在浮点误差范围内。这说明手写版本的理解是对的。
更有用的是通过方差解释率决定保留多少个维度。explained_variance_ratio_告诉我们每个主成分承载了总方差的百分之多少。
# 保留累计方差解释率达到95%的维度 pca_95 = PCA(n_components=0.95) X_pca_95 = pca_95.fit_transform(X_scaled) print("保留95%信息需要的主成分数量: ", pca_95.n_components_) print("各主成分方差解释率(前10个): ", pca_95.explained_variance_ratio_[:10])这个数字在实验报告里很有价值。原始特征是64维,但往往只需要二三十个主成分就保留了95%以上的方差,说明大量维度其实是冗余的,它们对区分样本类别的贡献很小。报告中写清楚"原始维度、保留维度、方差解释率"这三项,比单纯贴一张二维散点图有说服力得多。
2.4 手写PCA时的三个常见坑
第一个坑上文已经提到过,特征值排序。可以顺手写一个断言:如果某个库返回的特征值是从小到大,记得反转。
第二个坑是标准化之后的数据才能拿去算协方差。如果不标准化,PCA的结果会被量纲大的特征主导,这不算报错,但结果没有意义。
第三个坑和维度规模相关。当特征数量非常大时,直接计算协方差矩阵再特征分解,内存和时间消耗都很大。sklearn的PCA底层会使用截断SVD或随机化SVD来绕过这个问题。手写版本理解了原理就行,真正处理上万维数据时还是建议直接用好官方封装。在报告里可以提一句"实际大规模场景采用SVD途径求解",显得你是真的懂。
3. 从无监督到有监督:LDA如何把标签信息用起来
3.1 PCA与LDA的目标差异
PCA做降维的时候只看数据本身的方差结构,完全不关心样本属于哪个类别。这带来一个很现实的问题:如果不同类别的差异恰好不是沿最大方差方向分布的,PCA投影之后类别会混在一起。
LDA(线性判别分析)的思路完全不同。它做投影时明确使用了标签信息,目标是让投影后的数据"同类尽量紧凑、异类尽量分开"。
打个比方,PCA像是一个摄影师站在远处拍全景,哪里画面丰富就往哪里拍,但主角可能只占画面很小的位置;LDA像是专门找构图,让画面里的每一个人都站得清楚、分得明白。所以在分类任务中,相同维度下LDA往往比PCA得到更好的可分性,这正是实验项目安排LDA的原因:让你体会"有没有用标签"对降维结果的影响。
3.2 散度矩阵视角下的LDA
LDA的数学表达式比PCA稍微绕一点,但本质上还是特征值分解。
定义类内散度矩阵Sw,它描述了每个类别内部样本的分散程度;定义类间散度矩阵Sb,它描述了各类别中心之间的分散程度。LDA要找的投影方向w,是让以下比值最大化:
J = (w^T Sb w) / (w^T Sw w)
分子是类间距离,分母是类内距离。投影后类间距离大、类内距离小,分类自然就清晰。这个问题最终归结为对Sw^(-1) Sb做特征分解,取前几个特征向量作为投影方向。
有几个容易混淆的点需要说明。LDA最多降到类别数 - 1维,因为类间散度矩阵的秩最大就是类别数 - 1。比如手写数字是10个类别,那LDA最多降到9维,取2维只是可视化需求。另外Sw是可逆矩阵这一假设在高维小样本场景下经常不成立,后面会讲怎么处理。
3.3 sklearn实现与可视化
sklearn里LDA的实现封装得很好,调用方式和PCA很像,只是多了一个标签参数y。
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis lda = LinearDiscriminantAnalysis(n_components=2) X_lda = lda.fit_transform(X_scaled, y) print("LDA降维后: ", X_lda.shape)从结果来看,LDA把样本投影到二维空间之后,同类别的点明显比PCA的结果更聚拢。PCA投影图里,0和1等数字类别中间还会有不少交叉,而LDA的图里各个类别分得相对干净。这也是实验报告里适合放对比图的地方:两张图并排,一张无监督降维,一张有监督降维,结论一目了然。
3.4 高维小样本下LDA的协方差奇异问题
这是LDA最经典的一个坑。我们组里A同学在做实验时直接把一个上千维的数据喂给LDA,结果程序直接报错或者结果飘得离谱。原因就是样本数量比特征维度还少,类内散度矩阵Sw不满秩,不可逆,整个特征分解过程就失效了。
这种情况的常见解法有三种。
第一种是先做PCA把维度压下来,再用LDA继续降维,这也是很多实际系统里采用的组合方案。第二种是使用LDA的正则化参数shrinkage,它会给Sw加一个小的对角扰动,让矩阵变得可逆。第三种是增加样本量,但在实验环境里往往不现实。
选哪种取决于你的数据情况。如果特征维度几千、样本几百,PCA+LDA是比较稳的组合;如果维度不敢降太多,那shrinkage更合适。报告里能写出"我遇到什么问题、做了哪几步尝试、最终选择了什么方案",这部分就是加分项。
4. 非线性降维t-SNE:可视化高维流形的第一选择
4.1 线性方法的局限在哪里
PCA和LDA都属于线性降维,它们本质上是做一个矩阵乘法,把原始特征线性组合成新特征。问题在于,高维数据的结构很多是非线性的。
举个例子,你有一个高维空间里卷成环形的数据,用线性方法把它投影到二维平面,环形结构会被拉直或者压成一团,看不出原来的形态。只有把数据展开(unroll),才能看到背后的流形结构,而这是线性变换做不到的。
t-SNE(t-distributed Stochastic Neighbor Embedding)就是为这种场景设计的。它不要求投影是线性的,在高维空间里相邻的点,在低维空间里也会尽力保持相邻,从而把复杂的流形结构展开成人类能看懂的可视化结果。
4.2 t-SNE的机制直觉:概率分布与KL散度
t-SNE的核心分两步。
第一步,在高维空间里计算每两个样本之间的相似度。它把样本点之间的距离通过高斯分布转换为条件概率:如果两个样本距离近,对应的概率高;距离远,概率低。这个概率描述了"如果以某个点为中心,另一个点是它邻居的可能性"。
第二步,在低维空间里也用同样的方式计算概率,不过这里用的是t分布而非高斯分布。t分布比高斯分布有更厚的尾部,它的作用是:低维空间中稍微远一点的点,概率不至于衰减得太快,从而让不同簇的点更愿意彼此推开。
优化目标是让高维空间和低维空间的概率分布尽量一致,衡量两个分布差异的指标是KL散度。通过梯度下降不断调整低维点的位置,KL散度逐步变小,最终得到的低维布局就是高维结构的近似展开。
这里用T分布的原因值得在报告里写一句:如果低维空间也用高斯分布,投影后的点会挤成一团,簇与簇之间很难分开;使用t分布是为了缓解拥挤问题,让可视化更清晰。
4.3 参数调节与可视化代码
sklearn里t-SNE的调用同样简洁,但参数影响尤其是perplexity的影响非常大。
from sklearn.manifold import TSNE tsne = TSNE( n_components=2, perplexity=30, learning_rate=200, n_iter=1000, random_state=42 ) X_tsne = tsne.fit_transform(X_scaled) print("t-SNE降维后: ", X_tsne.shape)perplexity可以理解为"每个点周围的邻居数量"。值太小,比如5,算法会过于关注局部结构,图形碎片化严重;值太大,比如100,局部细节会被抹掉,所有点挤成一团。常规做法是在5到50之间试几个值,选择结构最清晰的那个。
learning_rate也很关键。值太小可能导致结果收敛到奇怪的局部最优,图看起来像一团被压缩的云;值太大会让优化过程震荡。200通常是个不错的起点。
迭代次数n_iter建议保持1000以上,尤其是数据量大的时候,迭代次数太少的图会明显不成熟。
4.4 看t-SNE图时最容易被误解的地方
用t-SNE画出一张漂亮的图之后,有几个解读上的坑需要注意。
t-SNE每次运行结果可能不同,因为初始化是随机的。这不是bug,是算法的特性。实验报告里应该固定random_state,或者运行多次观察稳定的模式,而不是纠结于某一次的细节。
t-SNE图里的簇间距离没有绝对意义。两个簇之间隔得远,只说明高维空间中它们相似度低;但两条不同实验运行结果里,簇之间的距离不能互相比较。所以,读图时看的是"哪些点聚在一起、哪些点明显分开"这个相对关系,而不是距离的数值。
另外,t-SNE没有像PCA那样可以迁移的投影矩阵。新样本来了,不能简单地用transform把它映射到已有的低维空间,必须把新样本和原有数据放在一起重新跑一次t-SNE。这一点在实验项目中经常被忽略,但在真实业务里,这就是它主要被用于探索性可视化、而不是作为在线特征工程的原因。
5. 自编码器降维:从线性变换走向深度学习特征压缩
5.1 自编码器和PCA之间的血缘关系
PCA可以看作一个只含一层线性变换、没有激活函数的自编码器。编码部分把高维输入投影到低维瓶颈层,解码部分把低维表示还原回高维空间,整个模型的优化目标是最小化输入和输出之间的重构误差。在线性情况下,它的解恰好落在PCA主成分张成的子空间里。
区别在于,PCA的投影方向必须互相正交,而自编码器不要求这一点;更重要的是,一旦在隐藏层加上非线性激活函数,自编码器就能学到非线性降维。这就把深度学习引入实验,形成了一条从PCA到非线性自编码器的清晰脉络。
这里一个容易混淆的点是:自编码器不是用来做"表示学习"的唯一方法,但它足够直观。它没有利用标签信息,所以属于无监督降维,和t-SNE类似;但它训练好之后可以对任意新样本编码,这一点又比t-SNE更适合部署。
5.2 搭建全连接自编码器的核心结构
下面的模型结构适合64维输入,我做了一个包含瓶颈层8维的全连接自编码器。
import keras from keras import layers model = keras.Sequential([ layers.Input(shape=(64,)), layers.Dense(32, activation='relu'), layers.Dense(16, activation='relu'), layers.Dense(8, activation='relu', name='bottleneck'), layers.Dense(16, activation='relu'), layers.Dense(32, activation='relu'), layers.Dense(64, activation='sigmoid'), ]) model.compile(optimizer='adam', loss='mse') model.summary()编码器部分是前三层,把64维压到8维;解码器部分负责从8维还原回64维。瓶颈层是降维真正的产物,训练结束后提取这一层的输出,就是每个样本的8维压缩表示。
模型本身结构很简单,但几个设计点值得解释。瓶颈层维度需要根据任务设定,实验里我选了8维,这样既可以看到比64维明显的压缩效果,又保留足够的类别信息。输出层用sigmoid激活,是因为我希望模型把输出限制在0到1之间,这样可以配合数据归一化来做训练。
5.3 训练自编码器时最容易踩的归一化坑
这是整个实验里我碰到最隐蔽的一个问题。PCA实验里我们对数据做了标准化处理,特征服从均值为0、方差为1的分布。但自编码器的输出层是sigmoid,它的输出范围是(0,1)。如果输入特征是标准化之后的数值,模型的目标就变成了用0到1的输出去拟合范围在[-3,3]甚至更宽的数据,这从定义上就拟合不好,重构误差会一直降不下去。
正确的做法是为自编码器单独做一次MinMaxScaler归一化,把输入缩放到(0,1)区间,和输出层的sigmoid匹配起来。
from sklearn.preprocessing import MinMaxScaler minmax_scaler = MinMaxScaler() X_minmax = minmax_scaler.fit_transform(X) model.fit( X_minmax, X_minmax, epochs=50, batch_size=32, validation_split=0.1, verbose=0 )这里fit的输入和标签都是X_minmax,因为自编码器是自监督的,输入就是目标。
这个坑也提醒了一件事:不同降维方法对数据预处理的要求不同,不能因为PCA用了标准化,后面所有方法都照搬。每一步预处理都要问一句"这个方法和数据分布匹配吗"。
5.4 用瓶颈特征做分类与可视化验证
训练结束后,需要把瓶颈层的输出提取出来,验证降维后的特征是否真的保留了类别信息。
# 提取从输入到瓶颈层的映射 encoder = keras.Model(inputs=model.input, outputs=model.get_layer('bottleneck').output) X_encoded = encoder.predict(X_minmax) print("自编码器降维后: ", X_encoded.shape)用这8维特征去做分类任务,可以量化降维质量。我在实验里用随机森林做了交叉验证,发现8维压缩特征在分类准确率上能追平64维原始特征的九成以上,虽然略有下降,但训练时间大幅缩短。更关键的是,如果把瓶颈层换成2维再训练一个自编码器,画出散点图,能看到比PCA更清晰的类别分离。
这个对比值得写进报告:自编码器在同样2维的条件下,学到了PCA线性投影之外的类别区分结构,尤其是在数据分布不是线性可分的部分。它不需要标签,却能在低维空间里把类别分开,这展示的就是非线性表示学习的价值。
6. 四种方法放在一起:结果对比、选型思路与实验避坑
6.1 同一数据集上的量化对比结果
做实验最忌讳只输出结论不给对比过程。我把四个方法放在同一个数据集上做了统一对比,既看降维后的可视化效果,也看量化指标。
| 方法 | 输入特征 | 是否使用标签 | 降到几维 | 训练耗时 | 降维后分类效果 | 新样本处理 |
|---|---|---|---|---|---|---|
| 原始特征(基线) | 64维 | - | 64维 | - | 基线 | - |
| PCA | 标准化后64维 | 否 | 约20维(保95%方差) | 极快 | 接近基线,偶有提升 | 可 transform |
| LDA | 标准化后64维 | 是 | 2维(可视化用) | 快 | 分类边界清晰 | 可 transform |
| t-SNE | 标准化后64维 | 否 | 2维 | 较慢 | 不用于分类,只做可视化 | 不可,需重新计算 |
| 自编码器 | 归一化后64维 | 否 | 8维 | 中等 | 略低于基线 | 可 predict |
这个表格反映出的规律是:降维不是白白损失信息。PCA压缩到20维后分类准确率可以基本持平,原因是去掉了噪声和冗余维度;LDA分类边界最清晰,因为它直接用了标签信息;t-SNE图最漂亮,但不能作为分类特征使用;自编码器在不使用标签的前提下,做到了比线性方法更强的压缩表达。
6.2 按任务需求选型的决策逻辑
经常有人问,这么多降维方法,到底该用哪个。我的回答是,先明确你这个任务最在乎的是哪一环。
如果目标是把数据压缩后喂给下游分类模型,对可解释性要求高、数据规模又大,首选PCA。它稳定、快、可迁移,而且在很多问题上压缩后的效果反而更好。
如果手里有标签,目标就是提高分类准确率,LDA非常值得一试。尤其降维目标是二维三维可视化时,LDA在有监督场景下通常比PCA区分度高。
如果目标是探索性数据分析,想看看数据里有没有潜在的簇结构,用t-SNE。它适合发现非线性流形,但记住它不能用于在线预测,也不适合超大样本量,因为计算代价高。
如果数据规模大、模式复杂,又希望学出非线性压缩特征用于后续任务,自编码器是个方向。它比PCA表达能力强,但训练成本高,可解释性差。实验项目里设计这个部分,就是为了让你理解深度学习在特征表示上的定位。
6.3 实验中最让结果失效的几个坏习惯
这部分是我在带实验项目时最常碰到的问题,几乎每一次都有同学踩。
最严重的是数据泄漏。降维器必须在训练集上fit,然后把同一个模型套到测试集上transform。如果先在完整数据集上做fit_transform,再划分训练集测试集,测试集的信息已经渗透到降维模型里,后面的所有评估指标都会虚高。PCA的投影矩阵、LDA的投影方向、自编码器的权重,本质上都是"从数据学出来的参数",和模型参数一样,不能偷看测试集。
第二个坏习惯是不设随机种子。t-SNE、自编码器的初始化和训练过程都有随机性,不设种子的话,每次运行结果都不同,报告中写任何结论都没法复现。正确做法是在所有随机环节设置固定种子,例如random_state=42。
第三个问题是在报告里只用可视化图,不用量化指标。散点图看着分开就说降维效果好,不如补上具体数字,比如保留了多少方差、压缩后分类准确率相比基线变化多少、重构误差是多少。有了量化指标,结论才经得起推敲。
第四个问题是混淆PCA的"主成分"和"特征重要性"。PCA得到的新特征是原始特征的线性组合,你只能说某个主成分主要和哪几个原始特征相关,不能说"这个特征比那个特征重要"。这类表述在审查时非常容易被指出逻辑错误。
6.4 从实验走向真实业务:降维还能怎么用
项目做完之后,我在真实业务里遇到类似场景时会习惯性地多问一步:这个高维数据里有哪些结构是任务真正需要的。
实际用途大体可以分几类。第一类是特征工程,把上百维的原始特征压缩成几十维再进入下游模型,训练更快、泛化更稳。第二类是数据压缩和存储,尤其当特征维度极高时,用PCA或者自编码器把数据压到低维再存,需要还原时用重构,能省不少空间。第三类是去噪,自编码器可以学习从损坏输入恢复原始输入,这个过程能把噪声过滤掉。第四类是异常检测,用自编码器的重构误差来衡量一个样本是否偏离正常模式,偏离大的就是异常点,这在工业数据里很实用。
实验项目只是起点,把降维当成工具箱里的一件工具去理解它适合什么场景、不适合什么场景,价值就体现出来了。
这个项目做完以后,我自己最大的改变是遇到高维数据不再下意识直接丢进模型,而是先问一句:这里面哪些结构是任务真正需要的。如果你也正在做类似的实验项目,建议不要只把代码跑通就收工,试着把每个方法的投影可视化放在同一张图里做对比,再配上一张量化指标表,整个项目的说服力会完全不同。