☰
多分组差异分析火山图:从统计策略到高分期刊级可视化
2026/10/2 14:35:41 网站建设 项目流程

每次审稿人或者组会看到一张漂亮的火山图,我都会提醒自己:这张图背后最难的往往不是绘图本身,而是"多分组差异分析"这一层。尤其当你面对的不再是"处理组 vs 对照"这种简单二元比较,而是三个、四个甚至更多分组的实验设计时,统计策略和可视化方案必须同时想清楚,否则图再好看,审稿人一句"multiple testing correction是怎么做的"就能让返修变得异常痛苦。

我这一次要聊的,就是怎么为IF=33.2这种级别的高分期刊,把那套多分组差异分析火山图做得既统计严谨、又视觉上挑不出毛病。文章会覆盖最核心的统计策略选择、结果表的整理与阈值设置、三种常见可视化方案、高分期刊的细节打磨,以及我这些年实际操作中踩过的一堆坑。你就当是组里师兄把全套流程给你捋一遍,可以直接抄作业的那种。

1. 多分组差异分析:下笔绘图前先把统计策略定下来

1.1 为什么不能把所有组两两比较一遍再画图

我见过不少人拿到多分组数据的第一反应是:分组有N个,那就两两比较呗,每对比较出一张火山图,最后拼个大图。听起来很直接,但放到高分期刊的审稿人面前,这基本等于送人头。

原因是多层的。首先是最基础的多重检验问题。N个分组意味着 C(N,2) 对比较,4个组就是6对,5个组就是10对。每一对比较里,软件都会对几万个基因做一次独立的假设检验,这本身已经做了多重检验校正;可当你把多对比较的结果放在一起解读时,全局假阳性率是另一回事。你单独看某一对比较里面padj<0.05的基因,确实只有约5%的假阳性风险;但如果你看了6对比较,每对都拎出几百个显著基因,里面有多少是纯噪声?审稿人一定会问。

其次是生物学解释的问题。多分组实验通常背后的科学问题是"处理A和B分别相对于对照改变了什么""A和B的差异是否重叠""有没有基因只在某个处理下响应"。简单罗列两两比较,根本回答不了这些层次的问题。换句话说,你需要一个"全局筛子+局部细节"的组合,而不是一个扁平的比较矩阵。

我给了一个总结性对比,可以帮你快速判断自己需要哪种策略。

数据形态推荐分析路线绘图展示方式
多个处理组 vs 单一对照组全局ANOVA/LRT筛选 + 各处理组与对照的收缩估计比较每个处理组一个火山图面板,分面排版
多因素设计(时间点×基因型、药物×剂量等)多因素模型 + 感兴趣的对比提取火山图 + 交互项/主效应统计摘要
样本量大且分组多(>6组)limma趋势分析或伪bulk先降维热图/趋势图为主,火山图只取代表性对比

1.2 多分组差异分析的三条主流路线

先说第一种,也是我处理多分组表达谱最常用的一条:先做全局检验,再做两两比较。在DESeq2里,全局检验通常用似然比检验(LRT)实现。它的思路是把完整模型~ group和简化模型~ 1做比较,得到的p值回答的问题是:任何一个组之间是否存在差异。这个检验结果把基因分成"全局有变化"和"全局没有变化"两拨,先砍掉一批完全不动如山的基因,再对剩下的基因做两两比较,工作量更小、解释也更干净。

第二种路线是直接做感兴趣的两两比较,但不要做所有组合。绝大多数实验设计里,你真正关心的对比其实很少,比如多个药物浓度处理组,最核心的问题是"每个剂量 vs 对照""最高剂量 vs 最低剂量",可能加起来就三四对。这时直接构造contrast来跑Wald检验,再配上shrinkage,结果表干净,火山图也少,不用硬凑"全局检验"这种前戏。

第三种路线用于多因素设计。当你的分组不是一维的,而是两个或多个因素交叉而成时,比如"基因型(KO/WT)×处理(给药/不给药)",模型就要写成~ genotype + treatment + genotype:treatment。这时候主效应和交互项比单纯的组间均值差更有科学意义,差异分析也更应该围绕系数和contrast做,而不是把每个交叉组拉出来做两两比较。

1.3 DESeq2+LRT全局筛选+两两收缩估计的推荐配置

下面这套流程适用于绝大多数多分组转录组数据。核心有三个环节:先把设计矩阵设成分组因子,用LRT做全局筛选,再用Wald检验加lfcShrink做两两比较。注意LRT和Wald需要不同的DESeq对象状态,我建议分开跑,避免之前计算结果被覆盖。

library(DESeq2) # 假设 count_matrix 是基因×样本的表达矩阵 # colData 包含 group 这一列 coldata <- data.frame(row.names = colnames(count_matrix), group = factor(rep(c("Ctrl","TreatA","TreatB"), each = 3))) dds <- DESeqDataSetFromMatrix(countData = count_matrix, colData = coldata, design = ~ group) # 第一步:全局 LRT 检验,看任意组间是否有差异 dds_lrt <- DESeq(dds, test = "LRT", reduced = ~ 1) res_lrt <- results(dds_lrt) table(res_lrt$padj < 0.05) # 全局显著基因数量 # 第二步:Wald 检验 + apeglm 收缩,做两两比较 dds_wald <- DESeq(dds, test = "Wald") res_TA <- lfcShrink(dds_wald, contrast = c("group", "TreatA", "Ctrl"), type = "apeglm") res_TB <- lfcShrink(dds_wald, contrast = c("group", "TreatB", "Ctrl"), type = "apeglm")

我特别强调apeglm收缩这一步,是因为高通量数据里有大量低表达基因,log2FC的方差极大,如果不做收缩,你会得到一堆"倍数变化巨大但统计上不可靠"的假阳性点。apeglm能把这些噪声点的log2FC向0收缩,火山图的形态立刻变得干净,这也更符合高分期刊对可重复性的要求。如果apeglm报错或者不收敛,备选方案是type = "ashr",作用类似,接口也几乎一样。

有一个细节值得注意:全局LRT显著但两两比较不显著的基因,别丢。这类基因往往是"温和地多组都有变化"的类型,更适合用趋势分析或热图来呈现,而不是硬塞进火山图里当差异基因。

2. 火山图的输入数据与关键参数:阈值怎么定才不翻车

2.1 从差异分析结果表到绘图数据框

火山图的本质就是把差异分析结果表中的两列抽出来:一列是对数倍变化(log2FoldChange),另一列是对数转换后的调整p值(-log10 padj)。但实际绘图前需要做一个很关键的数据清洗动作。

结果表里有三类点是不能直接上图的:padj为NA的(低表达基因被独立过滤剔除)、log2FoldChange为NA或Inf的(某些基因在某个组里计数为0)、以及收缩后被裁掉的极端点。我的习惯是先统一过滤,再加标签列和分组列,组装成一套标准的绘图数据框。下面这个函数可以直接抄。

library(dplyr) make_volcano_df <- function(res_table, comparison_name) { res_table %>% as.data.frame() %>% tibble::rownames_to_column("gene") %>% mutate(comparison = comparison_name) %>% filter(!is.na(padj), !is.na(log2FoldChange), is.finite(log2FoldChange)) }

这里的小心思是:先过滤再处理,不只是为了ggplot不报错,更是为了不让NA点占据图例空间或干扰坐标轴范围。很多新手画出来的火山图坐标轴范围莫名其妙,就是因为有几个Inf点把横轴撑到了极大值。

2.2 倍数变化与显著性阈值的取舍逻辑

阈值的选择是火山图最容易引起争议的地方,也是审稿意见的重灾区。最常规的定义是:上调 = log2FoldChange > 1 且 padj < 0.05;下调 = log2FoldChange < -1 且 padj < 0.05。log2FoldChange = 1 对应表达量差2倍。

但"1和0.05"不是天然真理。我讲一下背后的逻辑:log2FC阈值本质是生物学意义的判断,你要想想你的实验体系里,1.2倍的差异算不算有意义的改变。有些数据集信号很强,可以放宽到0.5;有些组学数据噪声大,反而要用1.5甚至2来卡。padj取0.05还是0.01,则取决于差异基因数量和后续实验验证成本。如果按0.05筛选只剩下20个基因,而你下游还要做GO/KEGG富集,我建议放宽到0.1,或者把log2FC卡松一点。

我的经验是:审稿人更在意你是否清楚说明阈值依据,而不是阈值本身大小。所以不管选什么,图注里必须写清"the threshold were set as |log2FC| > 1 and padj < 0.05"这类话。

2.3 坐标轴设计里的三个坑

纵轴的坑最大。padj能达到1e-300甚至更小,直接取-log10后,纵轴上限可能到300,而绝大多数点集中在0到10这个区间,整张图会变成"贴着X轴的饼"。高分期刊的常见做法是:坐标轴设一个合理的显示上限(比如8或10),超出部分用点或者箭头向上集中表示。在ggplot里可以用coord_cartesian(ylim = c(0, 30))截断,而不是用scale_y_continuous(limits=...)去删点。

横轴的坑是对称性。如果处理组里下调很强(log2FC最低到-6),上调很弱(最高只有2),直接画会让左边看起来很夸张。我的建议是让横轴关于0对称,可用limits = c(-max(abs(log2FC)), max(abs(log2FC)))自动算。对称轴能避免读者误判上下调数量。

第三个坑是阈值线的位置。geom_hline(yintercept = -log10(0.05))时,-log10(0.05)约等于1.301;xintercept = c(-1, 1)时,别忘了你的阈值是绝对值。我见过有人拿"1.301"算成横线的位置,结果画到纵轴13去了,那显然不是同一个数值体系。

3. 多分组火山图三种可视化方案与R代码实现

3.1 方案一:分面火山图,最适合多组比较

多分组数据最保守、最不容易被形式主义审稿人挑刺的展示方式,就是每个比较组一个面板,用facet并排。它有两个天然优势:一是每个面板独立保持完整的点云形态,组间信息不互相干扰;二是可以在所有面板里保持相同的坐标尺度,直接视觉比较各组间的差异强度。

具体来说,每个面板一张火山图,面板标题写清楚比较对象,例如"TreatA vs Ctrl"和"TreatB vs Ctrl"。分面之后,我通常会在每个面板里标注自己最关心的基因。代码模板如下。

library(ggplot2) library(ggrepel) # 合并多个比较的数据框 volcano_data <- bind_rows( make_volcano_df(res_TA, "TreatA vs Ctrl"), make_volcano_df(res_TB, "TreatB vs Ctrl") ) volcano_data <- volcano_data %>% mutate(regulation = case_when( log2FoldChange > 1 & padj < 0.05 ~ "Up", log2FoldChange < -1 & padj < 0.05 ~ "Down", TRUE ~ "NS" )) # 每个面板内取padj最显著的前8个差异基因做标注 label_data <- volcano_data %>% filter(regulation != "NS") %>% group_by(comparison) %>% slice_min(padj, n = 8) p <- ggplot(volcano_data, aes(x = log2FoldChange, y = -log10(padj))) + geom_point(aes(color = regulation), size = 1.2, alpha = 0.7) + facet_wrap(~ comparison, ncol = 2, scales = "fixed") + scale_color_manual(values = c(Down = "#2166AC", NS = "#BDBDBD", Up = "#B2182B")) + geom_hline(yintercept = -log10(0.05), linetype = "dashed") + geom_vline(xintercept = c(-1, 1), linetype = "dashed") + geom_text_repel(data = label_data, aes(label = gene), size = 2.8, max.overlaps = 10, box.padding = 0.4) + theme_bw(base_size = 10) + theme(panel.grid = element_blank())

这里scales = "fixed"是故意设置的,目的是让两个面板共享同样的坐标轴,视觉上可以直接比较:如果TreatB的火山图明显"凝缩"在原点附近,说明它的转录组响应更弱。

3.2 方案二:单图多色叠加,适合比较组少的场景

如果只有2到3对比较,你可以不拆面板,而是把不同比较组的点叠加在同一个坐标系里。这个方案的视觉冲击力更强,也能直观展示"某个基因在哪个比较里显著"的共享关系。

具体做法是:保持横轴和纵轴不变,用颜色区分比较组,再用不同的形状或透明度区分上下调。听起来简单,但要防止一个经典翻车事故:多个组都显著的点颜色会叠加变浑浊,所以alpha一定不能太高,我一般用0.5。

# 只保留显著基因,非显著基因用浅灰色画一小块作为底衬 nonsig <- volcano_data %>% filter(regulation == "NS") sig <- volcano_data %>% filter(regulation != "NS") ggplot() + geom_point(data = nonsig, aes(x = log2FoldChange, y = -log10(padj)), color = "#D9D9D9", size = 0.8) + geom_point(data = sig, aes(x = log2FoldChange, y = -log10(padj), color = interaction(comparison, regulation)), size = 1.5, alpha = 0.6) + scale_color_manual(values = c( "TreatA_vs_Ctrl.Up" = "#D55E00", "TreatA_vs_Ctrl.Down" = "#0072B2", "TreatB_vs_Ctrl.Up" = "#F0E442", "TreatB_vs_Ctrl.Down" = "#CC79A7" )) + coord_cartesian(ylim = c(0, 30))

单图叠加的前提是组间log2FC和padj的范围不能差太多,否则信号弱的组完全被强的组遮住。如果你的数据集里有一个处理组响应特别剧烈,我建议老老实实回到分面方案。

3.3 方案三:差异矩阵/热图+火山图组合排版

高分文章里的多分组火山图很少孤立存在,常用套路是"全局概览+局部细节"的组合版面。比如用一个UpSet图或Venn图展示不同比较组之间差异基因的重叠关系,旁边放一对关键比较的火山图。这样读者先看到"有多少基因在处理A和B中都被影响",再看到"具体每个比较的效应量和显著性",逻辑非常清晰。

我是用patchwork包来拼图的。左侧放UpSet或热图,右侧放两到三个火山图面板,整体宽度控制在双栏宽度内。如果你用DESeq2的LRT筛出了全局显著基因,还会额外有一种组合玩法:火山图只画LRT显著的基因,不显著的基因用灰色小点垫底,这样不同面板之间会选择性地展示"具有全局差异潜力的基因"在不同比较里的表现,信息密度极高。

3.4 一个可直接跑的完整模拟示例

为了让你能完整跑通,我准备了一个模拟数据的小例子。它模拟了3组(Ctrl、TreatA、TreatB),每组3个生物学重复,共3000个基因,其中一批基因在TreatA中上调,另一批在TreatB中下调。

set.seed(123) n_genes <- 3000 n_samples <- 9 counts <- matrix(rnbinom(n_genes * n_samples, mu = 80, size = 0.4), nrow = n_genes, ncol = n_samples) rownames(counts) <- paste0("Gene", sprintf("%04d", 1:n_genes)) colnames(counts) <- paste0("Sample", 1:n_samples) # 人为引入差异 counts[1:150, 4:6] <- counts[1:150, 4:6] * 10 # TreatA 高表达 counts[151:250, 7:9] <- counts[151:250, 7:9] * 0.1 # TreatB 低表达

然后运行上一节给的DESeq2流程,生成res_TA和res_TB,再直接跑3.1的分面火山图代码。整个过程大概就十多行,模拟数据跑出来应该能看到两组火山图呈现出完全不同的偏移模式:TreatA面板右上角有密集红色点,TreatB面板左上角有密集蓝色点,非常适合拿来练手。

4. 高分期刊里火山图的细节打磨:从"能用"到"好看且规范"

4.1 配色不只是审美问题

每次看到有人用红绿配色画火山图,我心里都咯噔一下。红绿色差对红绿色盲读者几乎是不可见的,这在投稿层面属于硬伤。高分期刊由于读者群体更大更国际化,对图像的可访问性检查越来越严格。我的首选配色是蓝色-灰色-红色系:下调用 #2166AC(深蓝),不显著用 #BDBDBD(浅灰),上调用 #B2182B(深红)。这个组合在黑白打印时也有区分度,且对色弱人群友好。

还有一个容易被忽略的细节:点的大小。如果基因数超过2万,不能用大点硬叠,否则中心区域会糊成一团黑色。我一般把点大小控制在1到1.5,透明度0.5到0.7。点太少或太稀的时候再适当放大。

4.2 基因标注的艺术

高分文章的火山图不会把几千个显著基因全标上名字,那只会让图变成一锅粥。标注的原则是:标注你知道要讲故事的基因,而不是标注统计上最极端的基因。审稿人看到图上标了几个自己关心的通路基因,会觉得作者有生物学判断;看到标了一堆"log2FC最大"的基因,反而会觉得作者不假思索。

具体操作上,我通常手动维护一个候选基因列表,再用geom_text_repel只标注它们。如果没有预设列表,可以退而求其次:在每个面板里按padj排序,取前10个显著基因标注。但这时候我会在方法部分写上"the top 10 significant genes were labeled",避免被质疑选择性展示。

标注数量我习惯控制在每面板8到15个之间。超过15个标签就会开始打架,就算ggrepel能推开,读者也容易看串行。同时给标签设一个max.overlaps参数,防止ggrepel在老版本里无限循环。

4.3 导出格式、尺寸与字体规范

高分期刊对图片格式的要求通常写在作者须知里,但大体逃不出这几条:优先矢量图(PDF或SVG),位图必须300dpi以上,色彩模式CMYK可选。我的习惯是直接ggsave出PDF,再附带一张600dpi的TIFF预览,这样在线投稿和线下评审都能覆盖。

尺寸方面,单栏图宽约85到90mm,双栏图宽约180到190mm。我用R出图时习惯用英寸:单栏width = 3.5, height = 3,双栏width = 7, height = 5。一个常见的错误是把分面火山图的列数设成与面板数相等,导致整体宽高比例失衡。三到四个比较组分两列排,比一行四列要好看得多。

字体是个小细节但很影响观感。ggplot2默认的Arial或Helvetica在期刊排版里通常没问题,但你要注意字体大小:轴标签和标题的字号不要小于7pt,主图区至少9pt。太小的字在印刷后完全看不清,审稿人会觉得图做得粗糙。

4.4 图例与统计注释的完整性

最后一条关于完整性的清单,可以用来自查:图里有没有说明上下调的定义标准?如果没有在正文说明,必须在图注里写清楚。图例里Up/Down/NS三种类别的颜色是否有对照说明?纵轴是-log10(padj)、横轴是log2FoldChange,轴标签写全了吗?阈值线是0.05还是0.01,虚线旁边是否需要数值标注?

我建议把差异分析参数集中写在一个图表标题下,例如 "Volcano plots of differentially expressed genes. Dashed lines indicate |log2FC| = 1 and padj = 0.05. Up: red; Down: blue."。这句话看起来只是例行公事,但真有一个审稿人专门挑我图注没写阈值,所以我现在一律先写为敬。

5. 常见问题与排查技巧实录

5.1 火山图全是灰点,几乎看不到显著点

这大概是多分组差异分析里最让人崩溃的画面。原因通常是三种:第一,阈值定得太严,比如在数据量很小的实验里强行用padj<0.05加|log2FC|>2,自然筛不出几个;第二,你画的其实是某个处理组相对另一个处理组,而那两组本身转录谱就很相似,差异本来就小;第三,你的数据用了错误的标准化方法,比如直接对count做log2然后就拿来做图,根本就没做差异分析。

排查思路是回到结果表去看summary(res)输出的数字:显著基因到底有多少个,区间分布如何。如果summary显示几百个显著基因但图上没点,多半是你的过滤条件把坐标轴范围带偏了;如果summary本身就显示"0 padj<0.05",那是统计策略或数据质量的问题,不是绘图问题。

5.2 大量NA与inf点导致坐标轴异常

不加过滤直接画图的典型症状是横轴范围到了几百,纵轴范围到了几百,图中只有稀稀拉拉几个点。这就是Inf和NA在作怪。我的处理最早提到过,统一走make_volcano_df的过滤流程。还有一个坑是DESeq2对某些低 counts 基因会输出log2FoldChange很大但padj为NA,如果不过滤,它们就会以"异常巨大的点"身份出现在图上,影响所有点的视觉分布。

5.3 分面图坐标轴混乱,无法跨面板比较

分面时没写scales = "fixed",ggplot会默认让每个面板的坐标轴范围独立自适应,这在某些场景是优点,但在多分组火山图里是个坑。因为你要让读者跨面板比较效应量,坐标轴不一致会让比较失真。反过来,如果各组范围差异太大,固定坐标轴后某个面板几乎全被压缩到原点,这时候我宁可放宽到scales = "free_y",同时在图注里注明这一点。

5.4 标签重叠与ggrepel包冲突

geom_text_repel是一个好用但偶尔闹脾气的函数。常见问题有三个:标签数量太多导致排版极慢;max.overlaps参数过低导致大部分标签消失;在循环里多次调用ggrepel导致R会话崩溃。我的建议是先裁好标签数量(≤15),再逐面板作图,并把seed参数固定下来,比如seed = 42,这样每次跑出的标签位置一致,不会今天一个样明天一个样。

5.5 apeglm收缩估计报错

第一步跑lfcShrink时报错"model matrix is not full rank"或收敛警告,通常跟数据设计有关。备选方案是改用ashr:type = "ashr"。它不需要估计离散度参数,速度稍慢但对复杂设计更宽容。使用方法完全一致。

最后一个实用提醒:多分组差异分析火山图的复现性,比单组比较重要得多。高分期刊的审稿人现在越来越关注代码可复现性,所以请务必把你用的DESeq2版本、lfcShrink的type、随机数种子都写进方法部分。建议所有分析从一个R脚本文件跑到尾,不要手动改中间步骤。

关于多分组火山图,我目前最深的体会是:统计策略决定图有没有资格存在,可视化细节决定图好不好看。你先想清楚全局检验和两两比较的层级,再谈配色、标签和导出;千万不要一上来就把所有组两两拉一遍,那不是省事,是给自己后面补分析埋坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询