052

化学空间可视化:PCA、t-SNE 与 UMAP 怎么用

PCA、t-SNE 与 UMAP 各有适用场景与解读陷阱。这篇讲清怎么用它们看化学空间,以及不能从图里读出什么。

把高维的分子表示投影到二维平面,能直观看到化合物库的结构、数据集的覆盖、模型的适用域但降维图极易被过度解读——理解每种方法保留什么、丢失什么,才能正确使用。

三种方法的对比

PCA t-SNE UMAP
类型 线性 非线性 非线性
保留什么 全局方差结构 局部邻域 局部 + 部分全局
簇间距离有意义吗 没有 部分有
速度 最快 较快
可复现 确定性 依赖种子 依赖种子
可投影新点 可以 不能 可以
参数敏感性 高(perplexity) 中(n_neighbors)

最关键的差异:t-SNE 图中簇与簇之间的距离没有意义。两个簇画得远,不代表它们在化学上差异更大——这是最常见的误读

实现

import numpy as np
from rdkit import Chem
from rdkit.Chem import rdFingerprintGenerator, Descriptors

gen = rdFingerprintGenerator.GetMorganGenerator(radius=2, fpSize=2048)

def featurize(smiles_list, mode="fp"):
    X = []
    for s in smiles_list:
        m = Chem.MolFromSmiles(s)
        if m is None:
            continue
        if mode == "fp":
            X.append(np.array(gen.GetFingerprint(m)))
        else:
            X.append([Descriptors.MolWt(m), Descriptors.MolLogP(m),
                      Descriptors.TPSA(m), Descriptors.NumHDonors(m),
                      Descriptors.NumHAcceptors(m),
                      Descriptors.NumRotatableBonds(m),
                      Descriptors.RingCount(m),
                      Descriptors.FractionCSP3(m)])
    return np.array(X)

# ---- PCA ----
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

def pca_projection(X, n_components=2, scale=True):
    if scale:
        X = StandardScaler().fit_transform(X)
    pca = PCA(n_components=n_components, random_state=0)
    Z = pca.fit_transform(X)
    print(f"解释的方差比例: {pca.explained_variance_ratio_}")
    print(f"累计: {pca.explained_variance_ratio_.sum():.1%}")
    # 【关键】:如果前两个主成分只解释 15% 的方差,
    #          那么这张图丢失了大部分信息
    return Z, pca

# ---- t-SNE ----
from sklearn.manifold import TSNE

def tsne_projection(X, perplexity=30, seed=42):
    # 【建议先用 PCA 降到 50 维再做 t-SNE】
    #   → 加速且降噪
    if X.shape[1] > 50:
        X = PCA(n_components=50, random_state=0).fit_transform(X)
    return TSNE(n_components=2, perplexity=perplexity,
                random_state=seed, init="pca",
                metric="euclidean").fit_transform(X)

# 【perplexity 的影响很大】:
#   5    → 强调很局部的结构,簇多而碎
#   30   → 常用默认
#   50+  → 更全局的结构
#   → 【应该试几个值,看结论是否稳定】

# ---- UMAP ----
# pip install umap-learn
import umap

def umap_projection(X, n_neighbors=15, min_dist=0.1, seed=42,
                    metric="jaccard"):
    # 【对二值指纹,用 jaccard 距离而非欧氏距离】
    reducer = umap.UMAP(n_components=2, n_neighbors=n_neighbors,
                        min_dist=min_dist, metric=metric,
                        random_state=seed)
    return reducer.fit_transform(X), reducer

# 【UMAP 的优势】:
#   1) 比 t-SNE 快
#   2) 【可以 transform 新数据】
#      → 训练一次,之后可以把新分子投影到同一空间
#   3) 【支持 jaccard 等适合二值数据的距离】
#   4) 部分保留全局结构

可视化与着色

import matplotlib.pyplot as plt

def plot_chemical_space(Z, color_by=None, labels=None, title="",
                        cmap="viridis"):
    fig, ax = plt.subplots(figsize=(8, 7))
    if color_by is not None:
        sc = ax.scatter(Z[:, 0], Z[:, 1], c=color_by, s=8,
                        alpha=0.6, cmap=cmap)
        plt.colorbar(sc, ax=ax)
    else:
        ax.scatter(Z[:, 0], Z[:, 1], s=8, alpha=0.6)
    ax.set_title(title)
    ax.set_xlabel("维度 1")
    ax.set_ylabel("维度 2")
    return fig

# 【着色维度决定了这张图回答什么问题】:
#
#   按活性着色
#     → 【活性分子是否聚集在特定区域?】
#     → 如果分散,说明多个不同的作用模式
#
#   按数据集来源着色(训练/测试)
#     → 【划分是否合理?】
#     → 测试集与训练集完全重叠 = 划分太松(见 051)
#
#   按模型预测误差着色
#     → 【模型在哪些区域表现差?】
#     → 系统性的误差区域提示适用域边界
#
#   按骨架/聚类着色
#     → 【化学系列的分布】
#
#   按物化性质着色(logP、MW)
#     → 库的性质覆盖
#
#   按不确定性着色(见 166)
#     → 【模型在哪里没把握】
#
# 【多个数据集叠加】:
#   自家化合物库 vs 商业库 vs 已上市药物
#   → 【直观看出「我们在化学空间的哪个位置」】
#   → 常常是有启发的:
#     很多公司的库集中在很小的区域

解读的陷阱

# 【陷阱一:过度解读簇间距离】
#   t-SNE 图中,两个簇画得远
#   → 【不代表它们化学上差异更大】
#   → t-SNE 只保证「近的还是近的」
#
# 【陷阱二:把簇当作有意义的分类】
#   降维产生的簇可能只是算法的产物
#   → 【应该用原始空间的聚类验证】(见 050)
#   → 或人工检查簇内的分子是否真的相似
#
# 【陷阱三:忽略解释的方差】
#   PCA 前两个主成分可能只解释 10~20% 的方差
#   → 【图上看起来分开的点,在完整空间中可能很近】
#   → 【必须报告解释的方差比例】
#
# 【陷阱四:参数敏感性】
#   换一个 perplexity 或随机种子,图可能大不相同
#   → 【任何从图中得出的结论,
#     都应该在多个参数下验证】
#
# 【陷阱五:距离度量不匹配】
#   二值指纹用欧氏距离
#   → 【不符合化学相似性的直觉】
#   → 应该用 jaccard(= 1 - Tanimoto)
#
# 【陷阱六:把可视化当作结论】
#   降维图是【探索工具】,不是【证据】
#   → 从图中发现的模式,
#     应该在原始空间中定量验证

# 【一个稳健性检查】:
def stability_check(X, method="umap", n_seeds=3):
    """用不同种子生成投影,看结论是否稳定"""
    projections = []
    for seed in range(n_seeds):
        if method == "umap":
            Z, _ = umap_projection(X, seed=seed)
        else:
            Z = tsne_projection(X, seed=seed)
        projections.append(Z)
    # 【比较:同一对分子在不同投影中的相对位置是否一致】
    return projections

实用的应用场景

场景 做法
检查数据划分 训练/测试着色,看是否重叠(见 051《Scaffold Split》
诊断模型的适用域 按预测误差着色(见 166《不确定性估计》
评估库的多样性 看覆盖的面积与密度(见 053《分子多样性评估》
比较不同的库 叠加显示
展示筛选结果 标出命中分子的位置
跟踪项目进展 不同时期的分子着不同色
与同事沟通 一张图胜过一堆数字

最有价值的用法是「发现意料之外的模式」:比如发现活性分子集中在两个完全分开的区域(提示两种结合模式),或发现模型误差大的分子集中在某个区域(提示适用域边界)。但发现之后必须在原始空间中定量验证。

关键要点

  • t-SNE 图中簇间距离没有意义——这是最常见的误读;
  • PCA 必须报告解释的方差比例——只解释 15% 的图丢失了大部分信息;
  • 二值指纹应该用 jaccard 距离而非欧氏距离;UMAP 支持且可投影新数据;
  • 降维图是探索工具不是证据——发现的模式必须在原始空间定量验证。

延伸资源