表达矩阵预处理:低表达基因过滤与归一化

拿到表达矩阵后,很多人上来就扔进模型里跑。我早期也这么干过,结果模型训练半天loss死活不降。后来才发现,原始计数矩阵里的垃圾特征太多,把模型都带偏了。

预处理算是整个流程里最“脏”的活,但它直接影响后续所有分析的质量。说白了,这一步做不好,后面全白搭。

为什么要过滤低表达基因?

一个典型的RNA-seq数据里,可能有三分之一的基因只有零星几条reads。这些基因在大部分样本里都是零,偶尔冒个1、2,纯粹是技术噪声。把它们留在矩阵里,会带来几个麻烦:

  • 噪声干扰统计检验,false positive暴涨
  • 归一化时被大量零值拉偏
  • 模型训练时增加维度和计算开销,毫无收益
  • 降维可视化时,零基因会把样本点挤在一起

我记得有个项目,客户给的乳腺癌数据包含60000多个基因,过滤后只剩不到18000。模型AUC直接从0.72提到了0.81。你想想看,省下三分之一的特征,效果反而更好。

常用的过滤策略

过滤没有绝对标准,但业内有一些约定俗成的做法。下面这张表总结了最常见的几种:

策略 规则 适用场景
基于总计数 过滤总counts < 10 的基因 极低深度数据,快速粗筛
基于CPM阈值 至少n个样本的CPM > 1 最常用,推荐n≥3
基于表达样本比例 在m%样本中counts > 0 组间比较时,保留在至少一组内表达的基因
基于方差 保留方差最大的top N个基因 纯降维或聚类,不适用差异表达

我个人习惯用CPM阈值法,因为CPM可跨样本比较,选≥1的threshold能把大部分噪音滤掉。代码实现也很简单:

# R语言示例
cpm_matrix <- edgeR::cpm(count_matrix)
keep <- rowSums(cpm_matrix > 1) >= 3  # 至少3个样本CPM>1
filtered_matrix <- count_matrix[keep, ]

这里有个坑——如果你后续要做差异表达,过滤一定要基于原始样本分组分别做,不能在合并数据里一刀切。我吃过这个亏,把两组特有的低表达基因全干掉了,最后差异基因列表里啥也没剩。

归一化方法详解

过滤完,接下来就是让不同样本“公平对话”。不同样本的文库大小、基因长度、GC含量都不一样,直接比counts没意义。

归一化方法有好几种,我在项目中换着用过,说说差异:

方法 全称 公式要点 能比较什么
CPM Counts Per Million counts / 文库大小 × 1e6 样本间基因表达横向比较
RPM Reads Per Million 同CPM(原名不同) 同CPM
TPM Transcripts Per Million 考虑基因长度,先除长度再归一化 基因内、样本间、跨研究比较
log2转换 log2(x + 伪计数) 通常log2(CPM + 1) 或 log2(TPM + 1) 线性模型、降维、可视化

CPM vs TPM:大多数场景下CPM就够了,尤其是做差异表达时。如果想比较不同基因的表达丰度,或者用到一些需要基因长度的下游工具,那就上TPM。我个人的经验是,做机器学习特征时,log2(CPM + 1) 基本不会翻车。

TPM的计算逻辑稍微绕一点,建议跑一遍代码体会:

# TPM计算伪代码
# 输入:counts矩阵、基因长度向量(bp)
rpk <- counts / (gene_length / 1000)   # 每千碱基reads
tpm <- t(t(rpk) / colSums(rpk) * 1e6)

log2转换的常见误区

log2转换几乎是必做步骤,但容易踩两个坑:

  • 伪计数太小:log2(x+0.5) 或 log2(x+0.01) 会在低表达区域产生剧烈波动。建议用 log2(x + 1),0就变成0,1就变成1,比较干净。
  • 转换时机不对:先归一化再log2,还是先log2再归一化?一定是先归一化(CPM、TPM),再log2。否则零值取对数后会变成负无穷。
我曾经在项目交付前夜发现模型的feature importance里,有个基因被莫名其妙排到了第一。排查了一天,发现是伪计数用了0.1,导致该基因在几个零表达样本里变成了 -3.32,直接扭曲了所有样本的距离。换成+1后一切正常。

预处理完整流程

从原始计数到可以喂给机器学习模型,整个预处理路径大概是这样的:

表达矩阵预处理流程 原始计数矩阵 低表达基因过滤 归一化(CPM / TPM) log2转换 机器学习模型 / 差异分析 💡 关键原则 先过滤后归一化 归一化必须在log2之前 常用过滤阈值 CPM > 1 in ≥3 样本 总counts > 10

如果你用Python,可以试试 pypx 或者 scanpy 的预处理管线,它们内置了过滤函数 filter_genes 和归一化 normalize_total、log1p。一行命令就能跑完上面整套流程。

实际项目中,我建议大家先做质量控制(QC),再做基因过滤。QC能看到样本的文库大小分布、基因检测数、ERCC spike-in表现等,这些信息能帮你决定过滤的阈值。无视QC直接过滤,很容易错过批次效应或样本污染。

嗯,预处理这块看似基础,但水挺深的。我记得有一次帮别人Review代码,发现他先log2了原始counts,再归一化,结果矩阵里全是NaN——因为零的log2是负无穷,再求和归一化就直接炸了。这种细节,踩过一次就再也忘不掉了。