数据预处理:归一化、缺失值处理与异常值过滤

数据预处理这一步,看着像杂活,实际上整条流水线的成败一大半都压在这。我踩过不少坑,后来才明白——原始表达量数据直接往下游扔,结果基本没法看。今天聊聊归一化、缺失值和异常值这三个核心动作,帮你把底子打扎实。

3.1 为什么必须归一化

说白了,不同样本的测序深度、文库大小、基因长度都不一样。你直接拿原始counts去比较,好比拿不同秤称出来的重量比大小——没意义。归一化的目标就一个:让样本间的表达量能公平对比。

不同场景要选不同的归一化策略。我习惯先想清楚一个问题:我的下游分析需要什么尺度的数据?

方法适用场景核心思想常用工具
TMM (Trimmed Mean of M-values)差异表达分析(edgeR, limma)去除高表达基因的干扰,取修剪后的均值比edgeR::calcNormFactors
RPKM/FPKM基因长度不同时比较基因间表达每百万reads每千碱基的reads数stringTie, cufflinks
TPM跨样本、跨基因比较先归一化基因长度,再归一化文库大小RSEM, Salmon, kallisto
CPM (Counts Per Million)快速粗筛,简单直接每个count除以总counts乘百万edgeR::cpm
Quantile Normalization芯片数据或多组学整合强制让每个样本的分位数分布一致preprocessCore

我的习惯:如果做差异表达,优先TMM。如果做基因表达丰度比较,用TPM。如果只是可视化,CPM够用。别一上来就Quantile——它抹掉了很多生物变异,我吃过这个亏。

3.2 TMM归一化实战

TMM是差异表达分析里的老大哥。edgeR的calcNormFactors默认就用它。原理不复杂:选一个参考样本,对每个样本计算M值(log2 fold change)和A值(平均log2表达量),砍掉两端的极端值,取加权平均。

上代码看看:

library(edgeR)

# 假设counts是基因×样本的表达矩阵
counts <- matrix(rpois(1000, lambda = 10), nrow = 100, ncol = 10)
rownames(counts) <- paste0("gene", 1:100)
colnames(counts) <- paste0("sample", 1:10)

# 创建DGEList对象
y <- DGEList(counts = counts)

# 计算TMM归一化因子
y <- calcNormFactors(y, method = "TMM")

# 查看归一化因子
y$samples$norm.factors

# 获取归一化后的CPM
cpm_norm <- cpm(y, normalized.lib.sizes = TRUE)

我曾经翻车的地方:calcNormFactors要求输入是整数counts。如果你手上有FPKM或TPM,千万别直接往里喂。老老实实回到原始counts矩阵。

3.3 RPKM/FPKM与TPM的区别

RPKM和FPKM当年很火,但现在我基本只用TPM了。为什么?RPKM的分子和分母用的都是总reads数,但总reads数本身受高表达基因影响很大。TPM先归一化基因长度,再归一化文库大小,这样跨样本比较的时候更稳定。

换算公式也好记:

# RPKM计算(手动实现)
rpkm <- function(counts, gene_lengths, total_reads) {
  rpm <- counts / (total_reads / 1e6)
  rpkm <- rpm / (gene_lengths / 1000)
  return(rpkm)
}

# TPM计算
tpm <- function(counts, gene_lengths) {
  rate <- counts / gene_lengths
  tpm <- rate / sum(rate) * 1e6
  return(tpm)
}

用Salmon或kallisto直接就能得到TPM,省心。我最近一个项目里处理10个公共数据集,全部用kallisto跑了一遍quant,然后直接读TPM,再转成log2(TPM+1)做后续分析,效果不错。

3.4 缺失值处理

表达量矩阵里出现0值是很正常的,但如果是技术性缺失(比如某个基因在某个样本里就是没测到),就需要处理。我个人把缺失值分成两类:

  • 技术性缺失:文库构建或测序失败导致。需要补值或删掉。
  • 生物学缺失:基因确实不表达。保留0值,别乱补。

区分这两类的方法:看同组内的其他样本。如果其他样本都有表达,就这个样本是0,大概率技术问题;如果整组都是0,那就是生物学沉默。

# 简单的缺失值标记
# 假设counts矩阵里0值占比过高可能是技术缺失
missing_ratio <- apply(counts, 2, function(x) sum(x == 0) / length(x))
# 如果某个样本缺失率超过80%,建议剔除该样本
samples_to_keep <- names(which(missing_ratio < 0.8))
counts_filtered <- counts[, samples_to_keep]

# 对个别基因的缺失值用KNN插补(谨慎使用)
library(impute)
# 注意:KNN插补前需要先做log转换或者至少保证数据不是counts
# 这里以log2(CPM+1)为例
log_cpm <- log2(cpm(counts_filtered) + 1)
imputed <- impute.knn(log_cpm, k = 10)
log_cpm_imputed <- imputed$data

注意:我一般不推荐对counts数据直接插补,更不要用均值填充。零膨胀的counts分布用均值填充会引入严重偏差。如果非要做,至少先用CPM或log转换。

3.5 异常值过滤

异常值分两种:样本级别的异常和基因级别的异常。样本级别的异常通常用PCA、聚类树、或者样本相关性热图来发现。基因级别的异常则根据表达量分布来检测。

我常用的样本异常过滤步骤:

  1. 计算每个样本的总reads数,看是否有数量级差异
  2. 做PCA,标记离群样本
  3. 计算样本间spearman相关系数,相关系数低于0.8的样本标记为可疑
  4. 检查基因检出率(表达量>0的基因数)
# R代码示例:基于PCA剔除离群样本
pca_res <- prcomp(t(log_cpm), scale. = TRUE)
# 计算每个样本在PC1和PC2上的马氏距离
mahal_dist <- mahalanobis(pca_res$x[, 1:2], 
                           center = colMeans(pca_res$x[, 1:2]), 
                           cov = cov(pca_res$x[, 1:2]))
# 以0.01的p值阈值筛选异常样本
outliers <- which(p.adjust(pchisq(mahal_dist, df = 2, lower.tail = FALSE), 
                           method = "fdr") < 0.01)
if (length(outliers) > 0) {
  message("发现异常样本: ", paste(colnames(log_cpm)[outliers], collapse = ", "))
  log_cpm_filtered <- log_cpm[, -outliers]
}

3.6 本章知识体系总览

下面这张图把预处理的关键流程串起来了,你看一眼就明白:

靶点关系批次效应校准流水线 · 数据预处理总览 原始表达量矩阵 归一化 TMM / RPKM / TPM / CPM 缺失值处理 KNN插补 / 整行删除 异常值过滤 PCA / 马氏距离 / 样本剔除 预处理后干净矩阵 差异表达分析 / 批次效应校准 / 可视化 💡 预处理顺序:先归一化,再处理缺失值,最后过滤异常值。 归一化方法的选择取决于下游分析;缺失值优先考虑生物学背景;异常值用PCA法效率最高。

3.7 常见问题与避坑指南

避坑1:不要对counts做log转换后再归一化

log转换前必须已经完成文库大小归一化,不然低表达基因的log操作会放大噪音。我见过有人直接把counts取log2(counts+1)然后做TMM——结果归一化因子全乱了。

避坑2:缺失值补全后记得检查分布

补完记得画个密度图,看看插补后的数值是否破坏了原始分布形态。如果出现突兀的峰值,说明插补参数要调。

避坑3:异常值不一定是坏样本

有时候离群样本恰恰是生物变异的体现。比如疾病组里一个极端表型的样本。我建议先看临床信息,再决定是保留还是剔除。

我个人的预处理checklist:

  • 输入数据确认是原始counts矩阵(整数,无负值)
  • 运行calcNormFactors,检查norm.factors是否在0.7~1.3之间,超出太多说明某样本质量差
  • 用boxplot看归一化后的logCPM分布是否一致
  • 用PCA看是否有明显离群点,结合实验设计判断
  • 导出clean表达矩阵,同时记录下所有被剔除的样本ID,放进论文方法部分

数据预处理这块看起来琐碎,但每一步都影响下游的结果。你想想看,归一化做错了,差异表达分析出来的差异基因可能有一大半是文库大小差异造成的,根本不是真正的生物学差异。所以别急着跑统计模型,先把数据整干净了再动手。