数据预处理与标准化
数据预处理这事儿,我刚开始做单细胞分析时经常忽略。心想“数据不就这么点事嘛,直接丢进去跑PCA不就行了?”结果呢?出来的图惨不忍睹——批次效应和生物学变异搅成一团,根本分不清谁是谁。后来我才明白,预处理做得好,PCA才能给你讲真话。
为什么预处理对PCA至关重要?
PCA的工作原理说白了就是找方差最大的方向。如果你的数据里某个基因的表达量是另一个的一千倍,那PCA的第一主成分肯定被这个基因给带偏了。你想想看,那还能看到真正的生物学结构吗?
我个人的习惯是,在跑PCA之前先问自己三个问题:
- 数据是否已经中心化?
- 各个特征的尺度是否一致?
- 有没有极端值或偏态分布需要处理?
答案写在预处理步骤里,而不是等结果烂了再后悔。
中心化(Centering)
中心化就是把每个特征的均值拉到0。PCA的协方差矩阵计算依赖均值,如果不中心化,第一主成分会直接指向均值所在方向,而不是数据真正的变异方向。我见过有人忘了这一步,画出来的PC1几乎就是样本平均值的连线图,一点意义都没有。
中心化操作:X_centered = X - X.mean(axis=0)
在sklearn中,PCA默认会进行中心化,但自己手动做一遍总是更安心。
缩放(Scaling)
缩放是为了让所有特征在同一个量级上。常用的方法是Z-score标准化:
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_raw)
# 结果:每列均值为0,标准差为1
注意:如果你要做UMAP或t-SNE,缩放也是标准操作。但PCA对尺度更敏感——因为PCA的方差计算就是基于尺度的。举个例子,如果基因A表达量在0-100,基因B在0-1,那基因A的方差天然就大,PCA会误以为它最重要。缩放之后,每个基因才有公平的发言权。
我的经验:对于单细胞RNA-seq数据,我通常先做log1p变换,再用ScaleData(或StandardScaler)做Z-score。顺序上最好先变换后缩放,否则log之前负值会报错。
对数变换(Log Transformation)
为什么需要对数变换?因为很多生物学数据(比如基因表达)是计数数据,分布严重偏态——大部分基因低表达,少数基因极高表达。这种长尾分布会让PCA过分关注那些高表达基因。做了log1p(log(1+x))之后,分布变得相对对称,PCA才能看到差异不大的基因之间的微妙变化。
避坑指南:我曾经在一个批量效应校正项目中直接对原始计数做PCA,结果第一主成分几乎全被线粒体基因和核糖体基因绑架。后来加了log1p变换,那些管家基因的权重才降下来,真正的亚群结构才浮现出来。
import numpy as np
# 对数变换(log(1+x))
X_log = np.log1p(X_raw)
# 然后做缩放
from sklearn.preprocessing import StandardScaler
X_final = StandardScaler().fit_transform(X_log)
实战代码演示
下面是一套完整的预处理+PCA流程,我用它来处理来自两个批次的数据。你可以直接复制跑一下,看看不同预处理对PCA结果的影响。
import numpy as np
import pandas as pd
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
# 模拟两个批次的数据
np.random.seed(42)
n_cells = 200
n_genes = 100
batch1 = np.random.negative_binomial(n=2, p=0.01, size=(n_cells//2, n_genes))
batch2 = np.random.negative_binomial(n=2, p=0.02, size=(n_cells//2, n_genes))
X_raw = np.vstack([batch1, batch2]) * 1.0
batch_labels = np.array([0]*100 + [1]*100)
# 不预处理直接PCA
pca_raw = PCA(n_components=2).fit_transform(X_raw)
# 只缩放不变换
X_scaled = StandardScaler().fit_transform(X_raw)
pca_scaled = PCA(n_components=2).fit_transform(X_scaled)
# 对数+缩放
X_log = np.log1p(X_raw)
X_log_scaled = StandardScaler().fit_transform(X_log)
pca_log = PCA(n_components=2).fit_transform(X_log_scaled)
# 绘制三张图对比
fig, axes = plt.subplots(1, 3, figsize=(15,5))
axes[0].scatter(pca_raw[:,0], pca_raw[:,1], c=batch_labels, cmap='coolwarm', s=5)
axes[0].set_title('Raw counts PCA')
axes[1].scatter(pca_scaled[:,0], pca_scaled[:,1], c=batch_labels, cmap='coolwarm', s=5)
axes[1].set_title('Scaled only PCA')
axes[2].scatter(pca_log[:,0], pca_log[:,1], c=batch_labels, cmap='coolwarm', s=5)
axes[2].set_title('Log1p + Scaled PCA')
plt.show()
不同预处理对PCA结果的影响
| 预处理方式 | 主成分方差分布 | 批次效应可见性 | 生物学信号分辨率 | 我的建议 |
|---|---|---|---|---|
| 不处理(原始计数) | 集中在高表达基因 | 被高表达基因掩盖 | 低 | 不推荐 |
| 仅中心化 | 仍受尺度影响 | 略有改善 | 中低 | 较少单独使用 |
| 仅缩放(Z-score) | 均衡,但偏态依旧 | 可部分暴露 | 中 | 适用于计数分布不极端的情况 |
| 对数变换 + 缩放 | 最均衡 | 清晰可见 | 高 | 推荐标准流程 |
| 平方根变换 + 缩放 | 中等均衡 | 尚可 | 中 | 替代方案(处理接近零inflated数据) |
从SVG流程图可以看出,预处理的分支选择直接影响PCA的输出。我个人偏好log1p + 标准化的组合拳,这在我处理过的十几套单细胞数据中,只有一两次因为数据特别稀疏(大量零值)而改用了平方根变换。其他情况下,这个组合都能让PCA乖乖听话。