1. 从高维到二维:为什么我们需要降维可视化?

想象一下,你面前有一大堆混杂在一起的、不同颜色的弹珠,你的任务是把它们按颜色分好类。如果弹珠只有红、蓝、绿三种颜色,这事儿很简单,眼睛一扫就能搞定。但如果这些弹珠有784种不同的“颜色”(或者说特征),而且它们不是摆在桌面上,而是漂浮在一个我们无法直接感知的784维空间里,你该怎么办?这就是我们在处理MNIST这类图像数据时面临的真实挑战。

MNIST数据集里的每张手写数字图片是28x28像素,拉平后就是一个784维的向量。对于训练好的神经网络,比如LeNet-5,它的中间层(比如倒数第二个全连接层)输出的特征,虽然维度降低了(比如84维),但这个数字对人脑来说依然是个天文数字。我们无法“看见”84维空间里的点云是如何分布的,更别说去理解模型到底学到了什么、不同类别的样本是否被很好地分开了。

这就是降维可视化登场的时候了。它的核心目标,就是充当我们的“维度压缩仪”和“空间投影仪”,把高维空间中复杂的数据结构,以一种我们能看懂的方式(通常是2D或3D)呈现在屏幕上。它不追求保留原始数据的每一个细节(那是不可能的),而是试图保留最重要的结构信息,比如“哪些点应该聚在一起”(类内紧凑),“哪些群体应该离得远”(类间分离)。

今天,我们就拿经典的MNIST数据集开刀,用三个在实战中非常流行的工具——t-SNE、UMAP和hypertools,来一场真刀真枪的对比。我会带你一步步复现整个过程,看看它们各自能把数据“画”成什么样子,分析背后的原理,更重要的是,分享我在使用中踩过的坑和总结出的选型经验。无论你是刚入门的新手,还是想优化现有流程的老手,这篇文章都能给你带来直观的参考。

2. 实战第一步:从LeNet-5中“抽取”特征

在对比降维算法之前,我们得先有“维”可降。直接对784维的原始像素点做降维不是不行,但那样看到的是像素空间的分布,意义不大。我们更关心的是经过神经网络“消化理解”后形成的特征空间是什么样子。这就像不是直接看食材,而是看大厨处理后的半成品,更能反映菜肴(分类结果)的风味。

这里我选择用经典的LeNet-5模型。我们不需要从头训练,可以直接加载一个在MNIST上预训练好的模型。关键的一步是修改模型的前向传播函数,让它不仅输出最终的分类结果,还把倒数第二层(也就是最后一个全连接层之前的那一层)的输出也“吐”出来。这一层的特征,通常被认为是富含语义信息的高级特征。

下面是我修改后的PyTorch模型前向传播代码片段。我增加了一个 emb 变量来捕获我们需要的特征:

import torch
import torch.nn as nn
import torch.nn.functional as F

class ModifiedLeNet5(nn.Module):
    # ... 初始化部分保持不变 ...
    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
        x = F.max_pool2d(F.relu(self.conv2(x)), (2, 2))
        x = x.view(-1, self.num_flat_features(x))
        x = F.relu(self.fc1(x))
        # 关键:在这里截取倒数第二层的输出作为特征
        emb = F.relu(self.fc2(x))  # 假设fc2是倒数第二层,输出84维
        x = self.fc3(emb)  # fc3是最后一层,输出10维(对应10个数字)
        return emb, x  # 同时返回特征和分类结果

模型准备好后,我们让它以“评估模式”跑一遍整个测试集。这里有个细节要注意,为了避免内存爆炸,我们通常不会一次性把整个数据集塞进模型,而是用数据加载器(DataLoader)分批处理。每处理完一批,就把得到的特征向量和对应的真实标签存起来。

model.eval()  # 切换到评估模式,关闭Dropout等训练专用层
embs_list = []
labels_list = []

with torch.no_grad():  # 非常重要!禁用梯度计算,节省内存和计算资源
    for images, targets in test_loader:
        images = images.cuda()
        features, _ = model(images)  # 我们只关心特征,不关心这轮的输出
        embs_list.append(features.cpu().numpy())
        labels_list.append(targets.numpy())

# 将列表合并成完整的NumPy数组
embs = np.concatenate(embs_list, axis=0)  # 形状大概是 [10000, 84]
labels = np.concatenate(labels_list, axis=0)  # 形状是 [10000]

拿到这个 embs (形状为[样本数, 84])和 labels,我们的“弹药”就备齐了。这84维的数据,就是接下来三位“画师”(t-SNE、UMAP、hypertools)要施展才华的画布。

3. 元老级画师:t-SNE的细致与随机

t-SNE(t-distributed Stochastic Neighbor Embedding)可以说是高维数据可视化的代名词了,很多人的第一张降维图都是用它画的。它的核心思想很巧妙:在原始高维空间,它计算每对样本点之间成为“邻居”的概率(距离近的概率高);在降维后的低维空间(比如2D),它也构建一个类似的概率分布。然后,它通过梯度下降,不断调整低维空间中点的位置,让这两个概率分布尽可能相似。

听起来很美好,对吧?但用起来,有几个“坑”你得知道。首先,我们看看用 scikit-learn 实现有多么简单:

from sklearn.manifold import TSNE
import matplotlib.pyplot as plt

# 初始化t-SNE,关键参数:降成2维,使用余弦距离,并行计算加速
tsne = TSNE(n_components=2, learning_rate=200, metric='cosine', n_jobs=-1, random_state=42)
# 执行降维转换
embs_2d = tsne.fit_transform(embs)

# 可视化
plt.figure(figsize=(10, 8))
for digit in range(10):
    idx = labels == digit
    plt.scatter(embs_2d[idx, 0], embs_2d[idx, 1], label=str(digit), s=5, alpha=0.6)
plt.legend(markerscale=3)
plt.title('MNIST Features Visualization by t-SNE')
plt.axis('off')
plt.show()

跑一下这段代码,你大概率会得到一张非常漂亮的图,10个数字的类别各自抱团,形成清晰的簇。但是,请你关掉程序,再跑一次。对,就现在。你会发现,两次生成的图,各个颜色簇的相对位置可能完全不同! 上次“0”和“1”挨着,这次可能“0”和“7”成了邻居。

注意:这就是t-SNE一个非常重要的特性——它不保持全局结构。它只关心“谁和谁是邻居”这种局部关系,至于两个簇在整体布局里是近是远,并没有绝对意义。所以,你千万不要从t-SNE的图中去解读“类别A和类别B在特征空间里本质更相似”这种结论。它的布局是随机的。

另一个参数 metric 也值得玩味。默认是欧氏距离(euclidean),但对于像我们这种从神经网络提取的特征,余弦距离(cosine)往往效果更好,因为它只关注向量的方向(相似度),而忽略了长度,这对于强调模式匹配的特征来说更合适。

实测感受:t-SNE画出来的图通常非常“干净”,类内紧凑,边界清晰,视觉上很舒服。但它有两个老大难问题:一是计算速度慢,数据量上万就有点吃力;二是结果具有随机性,不利于严格的对比和报告。我一般把它用作探索性数据分析的“第一眼”,快速感受数据的聚类倾向。

4. 后起之秀:UMAP的快速与全局观

如果说t-SNE是位追求局部细节完美的工笔画家,那UMAP(Uniform Manifold Approximation and Projection)就像是一位兼顾整体布局的写意画家。它诞生得晚一些,但凭借其理论优势和惊人的速度,迅速成为了很多人的新宠。

UMAP的理论基础是拓扑数据分析,听起来很高深,但我们可以简单理解:它假设数据是均匀地分布在一个高维的“流形”上,然后试图在低维空间找到一个拓扑结构(可以想象成连接关系)最相似的映射。这带来几个直接的好处:

  1. 速度极快:在处理大数据集时,UMAP的速度通常比t-SNE快一个数量级。
  2. 更好地保持全局结构:虽然也注重局部,但UMAP对簇与簇之间的相对距离关系保持得比t-SNE要好。
  3. 更少的超参数:很多时候用默认参数就能得到不错的结果。

安装UMAP很简单:pip install umap-learn。使用起来和t-SNE一样直观:

import umap
import matplotlib.pyplot as plt

# 初始化UMAP,同样降成2维,使用余弦距离
reducer = umap.UMAP(n_components=2, metric='cosine', random_state=42)
# 执行降维转换
embs_2d_umap = reducer.fit_transform(embs)

# 可视化
plt.figure(figsize=(10, 8))
for digit in range(10):
    idx = labels == digit
    plt.scatter(embs_2d_umap[idx, 0], embs_2d_umap[idx, 1], label=str(digit), s=5, alpha=0.6)
plt.legend(markerscale=3)
plt.title('MNIST Features Visualization by UMAP')
plt.axis('off')
plt.show()

你运行这段代码,并且多运行几次,会发现UMAP的结果稳定性比t-SNE高很多。虽然每次也有细微差别,但大的布局、各个簇的相对位置关系基本是一致的。比如,你可能会发现数字“4”和“9”的簇总是靠得比较近,而数字“1”的簇则独自在一边。这种模式在不同次运行中重复出现,就更有可能是数据本身的结构,而非算法随机性造成的。

实测感受:UMAP是我目前进行标准特征可视化的首选。它的速度快,结果稳定可解释,生成的图形同样清晰。尤其是在你需要对多个模型或不同层特征进行对比时,UMAP的一致性更能保证你看到的差异是来自数据/模型本身,而不是降维算法的噪声。不过,UMAP对于“最小距离”(min_dist)这个参数比较敏感,它控制低维空间中点的聚集程度。调得太小,所有点会糊成一团;调得太大,簇内部会过于松散。对于MNIST这种类别分离度好的数据,默认值通常就很好。

5. 瑞士军刀:Hypertools的一站式解决方案

前面我们分别调用了两个独立的库。有没有一种工具,能把各种降维方法、聚类、可视化都打包在一起,用一两行代码就搞定呢?这就是Kaggle推出的 Hypertools。它本身不是一个新算法,而是一个基于Matplotlib、Scikit-learn、UMAP等库构建的高级工具箱,主打的就是一个“方便”。

安装:pip install hypertools。它的核心魔法函数是 hyp.plot。想用t-SNE降维并画图?一行代码:

import hypertools as hyp
# 一行代码完成降维(TSNE)和绘图,hue参数指定根据标签着色
hyp.plot(embs, '.', reduce='TSNE', ndims=2, hue=labels, title='TSNE via Hypertools')

想换成UMAP、PCA、Isomap?只需要把 reduce 参数从 'TSNE' 改成 'UMAP''PCA''ISOMAP'。想看看3D效果?把 ndims 改成 3 就行了。这种设计对于快速迭代和对比实验来说,简直是福音。

更酷的是,Hypertools还内置了聚类功能。假设我们没有标签(无监督学习场景),可以先聚类,再用聚类结果来着色可视化:

# 先用K-Means聚类,假设我们想要10个簇
cluster_labels = hyp.cluster(embs, cluster='KMeans', n_clusters=10)
# 然后用聚类标签作为颜色,进行TSNE可视化
hyp.plot(embs, '.', reduce='TSNE', ndims=2, hue=cluster_labels, title='TSNE with K-Means Clustering')

我实测下来,用Hypertools画出的图和直接用原库画出的图,在核心效果上是一致的。但它省去了大量的样板代码,把颜色映射、图例生成、子图排列等琐事都处理好了。不过,它也有“黑箱”的一面,一些底层算法的精细参数调整不如直接调用原库灵活。所以,我的策略是:用Hypertools做快速探索和原型展示,当需要精细控制或深入分析时,再回归到Scikit-learn或UMAP的原生接口。

6. 同台竞技:效果对比与深度分析

光说不练假把式,我们把三者的结果放在一起对比,才能看出真章。我使用同一份从LeNet-5提取的84维MNIST测试集特征,分别用t-SNE、UMAP以及Hypertools封装的PCA方法进行2维可视化,得到了下面这个对比图(此处为文字描述,实际代码可生成并排子图):

方法 可视化效果核心特点 计算速度 (约,10000样本) 结果稳定性 全局结构保持
t-SNE 簇内非常紧凑,边界极其清晰,视觉分离度最好。 慢 (数十秒到分钟级) 低,每次运行布局变化大 差,簇间距离无意义
UMAP 簇内紧凑,边界清晰,整体布局美观。簇间相对位置有参考价值。 快 (数秒到十秒级) 高,随机种子固定后结果可复现 较好,能反映部分全局关系
PCA (Hypertools) 点云呈扇形或线性扩散,不同类别间有大量重叠。 极快 (毫秒级) 完全确定,无随机性 最好,严格保持方差最大方向

t-SNE 就像用了“美颜滤镜”,它强力地把每个簇“捏”在一起,并把它们推开,所以从观赏性上说,它常常得分最高。但这也是以牺牲全局结构和引入随机性为代价的。你无法从两张不同的t-SNE图中比较哪个模型的“类间距离”更大。

UMAP 找到了一个很好的平衡点。它生成的图同样清晰,但看起来更“自然”一些,不会把簇压缩得过于致密。最重要的是,它的可重复性和对全局结构的保持,让我们可以更放心地基于可视化结果做一些分析推理。例如,如果UMAP consistently地显示A类和B类靠得很近,而C类远离它们,这很可能意味着在模型的特征空间中,A和B的语义相似度更高。

PCA 作为线性降维方法的代表,在这里更像一个“基准线”。它完全保留的是数据方差最大的方向,结果稳定、可解释(主成分)。但从可视化角度看,对于MNIST这种复杂的非线性流形数据,PCA显得力不从心,类别混杂严重。这恰恰反证了t-SNE和UMAP这类非线性方法的必要性。

关于“噪点”:仔细观察任何一张降维图,你都会发现每个颜色的簇里,或多或少掺杂着几个其他颜色的点。这些点不一定全是模型分类错误。一部分可能是数据本身的模糊性(比如写得像“5”的“6”),另一部分则是降维过程本身的信息损失和扭曲造成的。t-SNE由于对局部结构极度优化,有时会把远离本簇主体、但恰好在高维空间处于边界位置的个别点“拉”到另一个簇里,形成视觉上的噪点。

7. 如何选择?我的实战经验与避坑指南

看了这么多,到底该怎么选?我结合自己多年的项目经验,给你梳理一个清晰的决策路径:

场景一:第一次探索未知的高维数据

  • 推荐:Hypertools + UMAP/TSNE快速扫描
  • 操作:直接用 hyp.plot,把 reduce 参数换成 ['PCA', 'TSNE', 'UMAP'] 等多个方法,快速生成一组对比图。一小时内,你就能对数据的聚类倾向、线性可分性有一个整体印象。Hypertools的便捷性在这里发挥最大价值。

场景二:需要稳定、可重复的图表用于论文或报告

  • 推荐:UMAP
  • 理由:可重复性是学术出版的基本要求。UMAP在固定随机种子(random_state)后能给出相同的结果,而且其全局结构的保持性让你的图表更有说服力。记得在论文中注明使用的参数,特别是 n_neighbors(默认15,控制局部邻域大小)和 min_dist(默认0.1,控制点间距)。

场景三:追求极致的局部分离度和视觉冲击力

  • 推荐:t-SNE
  • 注意:一定要在文中说明t-SNE的局限性,特别是其结果的随机性。可以展示多次运行的结果,或者说明“此图仅为示意局部结构”。不要基于单次t-SNE结果下任何关于全局关系的结论。

场景四:处理超大规模数据集(>10万样本)

  • 推荐:UMAP,或者t-SNE的近似算法(如Barnes-Hut t-SNE)。
  • 避坑:原始t-SNE的计算复杂度是O(N^2),数据量大了根本跑不动。UMAP的效率要高得多。此外,无论是哪种方法,在应用前,强烈建议先用PCA或随机投影将维度降到50-100左右,这能大幅减少噪声并加速计算,而且通常不会损失有效信息。

一个我踩过的坑:曾经有个项目,我用t-SNE可视化客户特征,发现两个客户群完美分离,兴奋地汇报了。后来换了UMAP,发现两个群有部分重叠。深入检查才发现,t-SNE那次运行的随机种子恰好把两个群“甩”得特别开,误导了我。教训就是:重要的结论,不要只依赖一种降维方法,更不要只依赖一次运行结果。 多方法交叉验证,结合实际的业务指标和聚类评估指标(如轮廓系数)来判断,才更稳妥。

降维可视化是一个强大的探索工具,但它不是严格的证明工具。它帮助我们形成直觉、发现线索、解释模型。t-SNE、UMAP和Hypertools各有千秋,UMAP因其在速度、效果和稳定性上的均衡,已成为我工具箱里的默认选项。但理解它们的差异,知道何时该用谁,才能让你在数据分析和模型调试中真正地“看得见”。下次当你面对一团高维迷雾时,不妨把这三位“画师”都请出来,让它们为你各画一幅肖像,对比之下,真相自会浮现。

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐