做单细胞转录组分析的人应该都有过这种经历:聚类注释做完,FindAllMarkers跑完,十几个细胞类型的结果摆在眼前,怎么展示却犯了难。火山图只能一张一个比较组,贴满PPT后根本看不出重点;热图确实好看,但它展示的是表达量,不是差异表达基因(DEG)的增减规模。后来我改用蝴蝶柱状图,一页图就能把所有细胞类型的上下调基因数量对称摊开,汇报时大家一眼就能看出哪种细胞类型发生了最剧烈的转录变化。这篇就把这个图的做法、原理和踩过的坑一次讲透。
这个图最适合两类场景:一是比较各细胞类型作为整体其他细胞群时的标记基因富集方向,二是比较疾病/刺激处理后各个细胞亚群中上调和下调DEG的数量变化。它不依赖复杂的R包,只用ggplot2的基础绘图功能就能实现,如果你正在给单细胞数据的结果做可视化,或者想找个好上手的案例练熟宽表转长表、coord_flip翻转坐标轴这些基本功,这篇应该能帮到你。
1. 为什么最终选了蝴蝶图:多细胞类型DEG展示的取舍过程
1.1 火山图和热图的局限
火山图在单一比较组里很好用,x轴是log2FC,y轴是校正后的显著性,右上角和左上角分别圈出上下调的关键基因。问题在于,单细胞分析里的DEG结果是多维度的:我们有B细胞、T细胞、巨噬细胞、成纤维细胞这些群体,每个群体都要和其余所有细胞做一次比较。你当然可以为每个cluster分别画火山图,但十几张图放在一起,听众需要逐个盯住坐标轴和标注才能提取信息,这种认知负担在汇报现场是很致命的。
热图则是另一个方向的局限。它能展示一组marker基因在不同cluster间的平均表达量,通过颜色深浅体现表达梯度,但如果你想回答的是“LPS刺激后,哪种细胞类型上调了最多基因、哪种细胞类型以下调为主”,热图只能靠颜色去猜,给不出直接的数目关系。而且热图经过行和列的聚类重排后,读图顺序经常会打乱细胞类型本身的生物学顺序,讲起来要费很多口舌。
1.2 普通条形图缺了什么
用ggplot2画按cluster分组的柱状图也是个常见做法。上调基因数和下调基因数各画一根bar,配色区分一下,信息其实都在。但我用了几次之后总觉得哪里不对劲:两组柱子并列排在一起,人眼必须做一次“脑内减法”才能比较出上下调的相对大小,而且不同cluster之间的整体变化趋势,被分散的柱条切碎了。
蝴蝶图把这个问题解决得很巧妙。它以中轴线为基准,把上调基因的数量做成向一侧伸展的柱条,下调基因的数量做成向相反方向伸展的柱条,整体形成对称的蝶形结构。看到这种图,受众首先感知到的不是精确数字,而是几何形态上的比例关系:哪个翅膀长、哪个翅膀短一目了然。当你想强调“巨噬细胞在刺激后上调了1120个基因而只下调了210个”“静息T细胞两侧几乎萎缩在中线附近”这种结论时,不需要指着柱条逐个念数字,图的形状本身就在讲故事。
1.3 蝴蝶图真正回答的生物学问题
我还要提醒一点:选择这张图,等于你在有意识地引导读者关注DEG的方向平衡。上调和下调的不对称本身是一种生物学信号。细胞被激活时,转录因子级联会导致大量基因表达上调,下调基因数目相对较少;而细胞进入静息或终末分化阶段时,可能会出现整体转录活动的收缩。用对称结构展示这种差异,能在多个细胞类型之间形成直观的“转录活跃度排序”。这也是蝴蝶柱状图在单细胞领域被越来越多论文采用的原因,它不是一个随意的绘图偏好,而是和问题本身匹配的可视化语言。
2. 数据这道坎:从FindAllMarkers到可绘图的长表格
2.1 两种常见的数据来源
画蝴蝶图前需要一张包含三个核心信息的表格:细胞类型、基因数、方向(上调或下调)。在单细胞分析里,这张表通常有两个来源。
第一种是直接用FindAllMarkers,把每个cluster和其余所有细胞比较。这种方式适合回答“每个细胞类型的转录特征是什么”,得到的DEG本质上是这个cluster的marker基因及其方向。第二种是有了处理和对照两组标签时,在每个cluster内部做组间差异分析,得到的是“这个细胞亚群在刺激条件下哪些基因发生了变化以及变化方向”。两种来源最终输出的表格结构几乎一样,核心几列都是cluster、avg_log2FC、p_val_adj,所以后续绘图代码可以完全复用。
2.2 过滤、统计与聚合
假设你已经运行过FindAllMarkers并得到了markers数据框,接下来直接这样处理:
library(Seurat) library(dplyr) library(tidyr) library(ggplot2) # 假设 seu 是你的 Seurat 对象 markers <- FindAllMarkers(seu, only.pos = FALSE, min.pct = 0.1, logfc.threshold = 0.25) # 只保留显著差异基因 sig_markers <- markers %>% filter(p_val_adj < 0.05) # 按细胞类型统计上下调基因数量 stat_df <- sig_markers %>% group_by(cluster) %>% summarise( up = sum(avg_log2FC > 0), down = sum(avg_log2FC < 0), total = n() ) %>% arrange(total)这里的关键是两个过滤条件。p_val_adj是校正后的显著性,一般取0.05,你数据量特别大时甚至可以收紧到0.01。avg_log2FC的符号决定上下调方向:正数代表该基因在当前cluster里的平均表达量高于其他细胞,负数则相反。注意FindAllMarkers计算的是cluster与所有其他细胞混合群体的比较,所以符号方向是相对明确的。
2.3 从宽表到长表:理解数据排列方式
现在的stat_df是一张宽表,每一行是一个cluster,每一列是up、down两个变量。ggplot2的柱状图更适合长表,也就是把up和down合并成一列“direction”,把数值放回另一列“count”。这种变换叫做pivot_longer,很多跑生物数据的同学在这里卡壳,其实理解一下就知道:你只是把原来横着排列的两根柱子“压扁”再“垒高”了。
plot_df <- stat_df %>% select(cluster, up, down) %>% tidyr::pivot_longer(cols = c(up, down), names_to = "direction", values_to = "count") %>% mutate( y = ifelse(direction == "down", -count, count), direction = factor(direction, levels = c("up", "down")) )我在mutate这一步就直接把down对应的count取成了负值。这是画对称柱状图的精髓:上下调不再是“两组柱子并排”,而是“从零中线出发,一个向右,一个向左”。负数在ggplot2里会被画在坐标轴零点以下,配合翻转坐标轴,就自然形成左右分布的翅膀。我见过有人把这一步省掉,然后强行用y轴坐标变换去实现对称效果,结果柱条的方向和填充色经常对不上,不如直接在数据里把符号算好干净。
3. 蝴蝶图的绘制核心:ggplot2对称柱状图的实现思路
3.1 核心绘图代码
数据准备好之后,画图本身只需要很短的代码。下面这段是我实际项目中在用的一套模板,可以直接替换数据使用:
# 计算最大对称范围,防止坐标轴截断柱子 max_y <- max(abs(plot_df$y)) p <- ggplot(plot_df, aes(x = cluster, y = y, fill = direction)) + geom_bar(stat = "identity", width = 0.6) + geom_hline(yintercept = 0, color = "black", linewidth = 0.6) + coord_flip() + scale_y_continuous( limits = c(-max_y - 20, max_y + 20), breaks = seq(-max_y, max_y, length.out = 5), labels = abs ) + scale_fill_manual( values = c("up" = "#E64B35", "down" = "#4DBBD5"), labels = c("up" = "上调", "down" = "下调") ) + labs(x = NULL, y = "显著差异表达基因数量", fill = NULL) + theme_minimal(base_size = 14) + theme( panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(), legend.position = "bottom" ) print(p)这段代码拆开来看,每行都有它存在的理由。
geom_bar(stat = "identity", width = 0.6)是核心图层。stat="identity"告诉ggplot2不要再做任何统计,直接拿y值画柱子;width是柱条宽度,0.6是个比较舒服的值,太宽了柱子粘在一起,太窄了又显得单薄。geom_hline(yintercept = 0)画出中间那条分隔线,这是蝴蝶图视觉上的对称轴,没有它整个图就是两根悬空的柱条,读者很难判断零点在哪。
3.2 coord_flip里藏着最容易搞混的点
coord_flip()把x轴和y轴交换,让细胞类型从水平方向变成垂直方向排列。很多新手会疑惑:都已经用负值表示下调了,再用coord_flip岂不是方向又变了?
这里想清楚坐标系就能明白。coord_flip之前,y轴的正方向朝上,负方向朝下,所以上调柱子向上长,下调柱子向下长。翻转之后,原本的“上下”变成了“左右”,向上变成向右,向下变成向左。于是右侧的翅膀是上调基因数,左侧的翅膀是下调基因数,这正是蝴蝶图的形态。如果你觉得左右方向和你的期望相反,可以在取负值时把up和down的符号反过来,或者用scale_y_reverse再翻一次。按我的经验,直接用负值方案最直观,后续改起来也最简单。
3.3 坐标轴标签和配色细节
scale_y_continuous里有两个容易漏的细节。第一个是limits的计算,我给最大绝对值加了一个小余量,避免最长的柱条顶到画布边缘。第二个是labels = abs,因为数据里down被存成了负数,y轴刻度上会显示负号,比如-300、-200,这张图的读者肯定不希望在数字前看到负号,abs把标签强制转为绝对值,刻度变成了300、200,只有方向上的差异体现在柱条的位置上。
配色方面,我用的是红色代表上调、蓝色代表下调。这是单细胞文献里最常见的约定,红色几乎天然和“激活、高表达”绑定,蓝色则相反。如果你考虑色盲读者,可以把红色换成橙色系,比如#E69F00,蓝色换成偏青的#56B4E9,色觉异常人群也能区分。
4. 从能用变好看:排序、配色、数字标注与基因标签
4.1 排序逻辑:让图替你说话
ggplot2默认按cluster列在数据框里的出现顺序分配x轴位置,而这个顺序在coord_flip后会反转显示。也就是说,数据框第一行的cluster在翻转后显示在底部。如果不调整顺序,你看到的图基本是杂乱无章的。
推荐的排序方式是根据实际想表达的故事来决定。我常用的是按total(上下调总数)从大到小排列,把最活跃的细胞类型放在图的最上方。代码是这样的:
cluster_order <- stat_df %>% arrange(total) %>% pull(cluster) plot_df$cluster <- factor(plot_df$cluster, levels = cluster_order)这里有一个关键点:coord_flip之后,因子水平的第一个会出现在坐标轴底部。所以如果希望总数最多的cluster显示在顶部,要让它在因子水平中排在最后一个。上面的代码中arrange(total)已经实现了这个关系,不必再手动rev。
4.2 在柱条上标注基因数量
画好基本形状后,加上文字标注可以让信息传达更精确。geom_text配合abs(y)取绝对值:
p + geom_text( aes(label = abs(y), x = cluster), position = position_dodge(width = 0.6), vjust = ifelse(plot_df$y > 0, 0, 1), size = 3.5, color = "grey20" )现实情况是,如果上下调柱条都加了文字,而两侧空间有限,标签经常会重叠。我建议只在柱子外侧悬空的位置放置标签,比如上调侧把vjust设为-0.2让文字留在柱条顶端上方,下调侧设为1.2让文字留在柱条末端下方。这个微调能极大改善出图质量,reviewer看细节时不会觉得你敷衍。
4.3 让基因名直接出现在柱条上
数字只是结果,有时你还想直观地展示每个细胞类型里最关键的几个基因。可以在统计每个cluster的上下调基因之外,把按log2FC排序后排名靠前的基因收集起来,再用geom_text标注上去。这种图信息量更大,但要注意适用场景:细胞类型超过十几个时,柱条空间会缩得很窄,基因名会挤成一团,所以我一般只在汇报少数重点细胞类型时用这个方案。
4.4 双组对比与重点高亮
有的人会问,如果有处理组和对照组各一张图,怎么放更清楚?我的做法是用patchwork包把两张蝴蝶图上下或左右拼在一起,保持坐标轴范围一致,读者就能直接对比同一细胞类型在两个条件下的翅膀长度变化。而如果只想突出某个关键细胞类型,可以让其他cluster整体变成灰色,只给目标cluster保留红色和蓝色填充。这种方式在生信相关的组会汇报里效果特别好,因为你的眼睛一到图上就被强高亮的“明星细胞类型”抓住了。
5. 实战中绕不开的坑:方向、阈值与画布比例
5.1 上下调方向反了怎么办
这个坑我踩得不轻。有次跑完FindMarkers(ident.1 = "treated", ident.2 = "control")拿到的avg_log2FC方向和之前FindAllMarkers的结果正好相反,绘制的初版蝴蝶图里,原本应该上调的基因全部出现在了下调翅膀里,整套数据的解读完全反了。
所以强烈建议在画图之前用一个已知基因做方向校验。比如你的实验里某个基因(例如炎症因子或管家基因)已知在处理后会升高,那就直接看它在处理组的平均表达量是不是确实高于对照组,再确认表格里对应的avg_log2FC符号为正。两步验证过后再跑数据,方向绝对不会错。另外,不同版本的Seurat里列名可能不一样,旧版是avg_logFC,新版是avg_log2FC,读进来之后用names(markers)检查一下列名再写代码,不要想当然。
5.2 阈值直接决定图长什么样
阈值选择对蝴蝶图形态的影响远大于绘图代码本身。FindAllMarkers默认logfc.threshold是0.25,这个阈值在细胞数多、测序深度高的时候会产生海量显著基因,画出来可能每个cluster都是几百上千的长柱条,所有翅膀看起来都差不多,信息量反而下降了。我常用的策略是跑完默认阈值之后,看一眼每个cluster基因数的中位数。如果中位数超过300,就把logfc.threshold上调到0.5甚至1.0,或者把min.pct提高,这样筛选出的DEG更有代表性,图也更清晰。
5.3 y轴limits限制和中轴线
如果你没有显式给limits,ggplot2会根据数据范围自动扩展坐标轴。这时候中轴线geom_hline(yintercept = 0)可能会被自动扩展的空白区域顶离视觉中心,导致图看起来歪歪扭扭的。我见过不少蝴蝶图画出来中线不在整个画布的中央,就是因为没管limits。建议像我前面那样先算max_y,再手动传limits,等于提前把画布的左右边界钉死,让中线严格落在正中。同时,如果某个cluster的负值超过了你定的max_y,柱条会被裁剪,数据没问题但视觉上缺了一段,需要每次改数据后重新计算范围。
5.4 零值处理和画布比例
有的细胞类型可能在一个方向上完全没有显著DEG,这时候柱条长度为0,紧贴中线。这种情况完全正常,甚至是一个重要的生物学观察点。但在绘图时要注意,如果某个cluster的up和down都是0,它在图里就是一条紧贴中线的短横线,容易被忽略。建议在汇报时白纸黑字把这个数值点出来,别让观众自己去找。
最后是画布比例的问题。coord_flip后,x轴方向有多少个cluster,就需要多少垂直空间。十个cluster用width=6、height=5英寸没问题;二十个cluster如果不把高度加到8英寸以上,柱条会被压成扁扁的细条,整个图看起来就像一组杂乱的地铁线路。我每次保存图片前都会先设定好设备尺寸再运行绘图代码,避免后期导出PNG时发现布局被强行拉伸。
整体来说,蝴蝶柱状图的实现难度不大,但它对数据预处理的要求比普通图高一些,所有对称效果都建立在数据符号正确计算的前提下。先花五分钟梳理清楚表格结构,再动手画图,整个过程会顺畅很多。