上周帮一位做湿实验的师妹整理数据,她把富集分析的结果表发过来,附了一句“师兄,有没有能在线画气泡图的网站?我不想学R”。结果我打开表格一看,第一反应不是帮她找工具,而是告诉她:“这图画不了,因为你给的表少了一列分组信息。”她一脸懵:“这不就是基因列表、通路名、p值都有了吗?”这件事让我意识到,很多人卡在“在线绘图”这一步,真正缺的往往不是工具,而是对图表背后数据结构的理解。
这篇内容我就围绕标题里的两个方向展开:一类是富集分析的多组气泡图,另一类是单细胞分析里的marker基因矩阵气泡图。两者都叫气泡图,但输入数据、绘图逻辑、适用场景完全不同。我会把数据格式怎么整理、代码怎么写、在线环境怎么跑通、常见的坑有哪些,一次性讲清楚。适合不想从零搭R环境、又希望拥有灵活定制能力的研究生和科研工作者参考,也适合已经把图做出来但总是差点意思、想优化到自己满意的同学。
1. 为什么都在找“在线”画法——两种气泡图的真实门槛在哪
先说说为什么这类需求这么普遍。富集分析气泡图是GO/KEGG结果的标配展示方式,单细胞marker基因气泡图是细胞注释环节的“标准动作”。但大多数人去找在线工具,不是因为这图有多难画,而是被环境配置劝退的:R要装、包要装、依赖还容易冲突,光是搞定一个clusterProfiler就能耗掉半天。对以实验为主的课题组来说,这个成本确实高。
但在我看来,真正的门槛从来不是“画”这一步,而是“整理数据”这一步。在线工具解决的是画图动作本身,可它没法替你决定该用哪列做横轴、哪列映射点的大小、哪列控制颜色。如果你不理解这些映射关系,哪怕把ggplot2代码原封不动贴到在线平台上,出来的图照样是乱的。
先给这两类图一个基本定位:
- 富集分析多组气泡图:展示的是一个统计表,本质上是“通路 × 比较组”的富集结果。横轴一般用GeneRatio或Count,纵轴是通路/条目名称,气泡大小代表富集到的基因数量,颜色代表显著性(p.adjust或qvalue)。多组意味着你需要在同一张图里分面呈现若干个比较组,组与组之间互相独立但又共享图例和视觉风格。
- 单细胞marker基因矩阵气泡图:展示的是一个表达矩阵的统计摘要。行是marker基因,列是细胞亚群,每个格子里其实是两个维度——气泡大小代表该基因在该亚群中表达的阳性细胞比例,颜色深浅代表平均表达量。这个图不是用来做富集检验的,而是用来证明“你这群细胞确实表达了XX基因、不表达YY基因”。
一眼就能看出,虽然都叫气泡图,但语义完全不同。很多人拿富集分析的结果去套单细胞气泡图的模板,或者反过来,自然做不出来。后面我会分别把两条线的数据准备和画法讲透,先从相对简单的富集分析多组气泡图入手。
2. 富集分析多组气泡图的输入数据格式:少一列都出不来
2.1 表格里必须有哪几列,少了会怎样
画任何图之前,第一件事是检查数据。富集分析气泡图的标准输入格式并不复杂,但每一列都有不可替代的用途。我把它整理成一张表,你直接对着检查:
| 列名 | 含义 | 映射到图的位置 | 缺失后果 |
|---|---|---|---|
| group | 比较组标签,例如“上调_up”、“下调_down” | 分面(facet)或填充 | 无法区分多个比较组,变成一坨点 |
| ID | 通路/条目ID,如GO:0006915 | 可用来排重 | 缺失可用Description代替 |
| Description | 通路描述,如“apoptotic process” | 纵轴标签 | 没有它,纵轴只能显示ID,可读性差 |
| GeneRatio | 富集基因数占通路基因数的比例 | 横轴位置 | 缺失时可用Count替代,但横轴含义会变 |
| Count | 富集到的基因数量 | 气泡大小 | 缺口会非常明显,图里所有点一样大 |
| p.adjust | 校正后的p值 | 气泡颜色 | 颜色无法映射显著性,图的价值打对折 |
单组数据缺一两列还能补救,多组数据一旦没有group列,问题就大了。因为在线工具通常只负责渲染,不具备帮你拆分比较组的智能。我见过很多次用户把三个比较组的结果直接堆在一个表里上传,工具压根不知道哪些行属于一组,只能把所有点叠在同一面板里,横轴基因占比全都错位。
2.2 GeneRatio和Count到底怎么选,两者有什么区别
GeneRatio是富集分析里一个容易被忽略的细节。以clusterProfiler为例,它在输出结果里给的GeneRatio是字符串形式,比如“25/114”,代表这条通路里富集到的25个基因占你输入基因列表总量114个的比例。如果你直接用这个原始字符串做横轴,R会把它当作离散因子,轴的排布会按字母序走,完全乱掉。
正确做法是做一个长表的时候就把GeneRatio拆成数值列,或者直接用Count列代替横轴。两种方案的差异在于:
- 用GeneRatio:图能体现不同通路之间的富集强度差异,适合做多组对比时消除输入基因总数的量纲影响。比如比较组A输进去2000个基因,比较组B输进去500个基因,如果横轴都用Count,B组所有点都会挤在左边,看不出趋势。此时打分比更公平。
- 用Count:更直观,读者一看到点的大小就知道富集到什么程度,但如果你把多个比较组放在一起,且各组输入基因数悬殊,误导性会比较强。
我的建议是:如果所有比较组来自同一次分析(输入基因数量相近),用Count没问题;如果各组输入基因数差很多,或者你希望图更“论文级”一点,就用解析后的GeneRatio。具体到在线绘制场景,你可以提前在Excel里加一列,把“25/114”手动拆成数值0.219,这比让工具去处理简单得多。
2.3 一个可直接对照的长表示例
为了让你直观理解什么叫“可以直接上传绘图工具”的数据,我给一个精简示例:
| group | ID | Description | GeneRatio | Count | p.adjust |
|---|---|---|---|---|---|
| 组1_up | GO:0006915 | apoptotic process | 0.219 | 25 | 3.2e-08 |
| 组1_up | GO:0006954 | inflammatory response | 0.132 | 15 | 1.5e-05 |
| 组1_down | GO:0008283 | cell population proliferation | 0.180 | 20 | 4.6e-06 |
| 组1_down | GO:0007165 | signal transduction | 0.088 | 10 | 8.1e-03 |
| 组2_up | GO:0006915 | apoptotic process | 0.110 | 12 | 2.2e-03 |
| 组2_up | GO:0006954 | inflammatory response | 0.046 | 5 | 3.0e-02 |
| 组2_down | GO:0008283 | cell population proliferation | 0.065 | 7 | 1.8e-02 |
这里需要注意两个细节。第一,Description这一列在不同比较组之间会重复,比如组1_up和组2_up都富集到apoptotic process,这是正常现象。但你在上传之前要确认同一个组内Description不重复,否则纵轴会出问题。第二,如果你想按p.adjust大小对通路排序,必须考虑分组。不同比较组的p值范围可能差几个数量级,如果全图统一排序,显著性低的组可能选不出代表性通路。一个稳妥办法是:每组先按p.adjust排序取前10到15条通路,再合并成长表。
3. 从云端的RStudio到出图:富集气泡图的完整落地链路
3.1 为什么我推荐“云RStudio”而不是花哨的网页生成器
市面上的在线绘图平台不少,很多生物信息学网站也提供了富集气泡图入口。但它们通常有一个共性问题:只能画单组、选色板有限、纵轴标签一旦过长就自动截断,而且当你需要一个杂志要求的特定分辨率或者排版风格时,完全无从下手。
所以我个人最常用、也最推荐给身边人的方案是:用云端RStudio(例如Posit Cloud这类提供R环境的在线服务,浏览器打开就能运行R,不需要在本地安装任何东西),配合ggplot2或者基于ggplot2的工具包完成绘制。它本质上就是一个跑在浏览器里的R环境,免费配额足够应付这类分析。
这样做有两个好处。第一,所有代码可复现,下一次换数据只需改路径和组名,不用重新学习平台操作。第二,对于“多组对比”这种需求,ggplot2的facet语法天然就是为这个场景设计的,比任何在线工具的封装都灵活。云端RStudio也会面临包安装的问题,但通常内置了常用包源,安装clusterProfiler这类包只是几分钟的事。
3.2 从上传数据到输出图片的完整代码实现
这里给一份可以直接跑的完整流程。你只需要把前面整理好的长表保存为CSV文件,比如go_multi.csv,然后按以下步骤操作。
# 1. 读取数据 library(ggplot2) df <- read.csv("go_multi.csv", header = TRUE, stringsAsFactors = FALSE) df$p.adjust <- as.numeric(df$p.adjust) df$GeneRatio <- as.numeric(df$GeneRatio) # 2. 按分组和p.adjust排序,保证每条通路在组内的位置合理 df <- df[order(df$group, df$p.adjust), ] # 3. 将Description转成因子,并按组内顺序固定轴顺序 # 这里用ave和rank生成组内顺序索引,再按索引排因子水平 df$desc_order <- ave(seq_len(nrow(df)), df$group, FUN = function(x) rank(df$p.adjust[x])) df$Description <- factor(df$Description, levels = unique(df$Description[order(df$group, df$desc_order)])) # 4. 核心绘图 p <- ggplot(df, aes(x = GeneRatio, y = Description)) + geom_point(aes(size = Count, color = p.adjust)) + scale_color_gradient(low = "#D32F2F", high = "#1E88E5", trans = "log10") + scale_size_continuous(range = c(2, 8)) + facet_wrap(~ group, scales = "free_y", ncol = 2) + theme_bw(base_size = 12) + theme( axis.text.y = element_text(size = 9), strip.text = element_text(face = "bold"), panel.grid.minor = element_blank() ) + labs(x = "GeneRatio", y = NULL) # 5. 输出高分辨率图片,论文常用300dpi ggsave("富集分析多组气泡图.pdf", p, width = 10, height = 7, dpi = 300) ggsave("富集分析多组气泡图.png", p, width = 10, height = 7, dpi = 300)这段代码里有几个关键点值得单独强调。
scale_color_gradient里用了trans = "log10",是因为p.adjust通常跨度非常广,从1e-30到1e-2都有,如果用线性色标,所有点都会偏向同一颜色,显著性差异根本显示不出来。取对数后颜色层次才拉得开。facet_wrap(~ group, scales = "free_y")是“多组”的核心。free_y意味着每个比较组的纵轴只展示该组富集到的通路,不会因为其他组通路多而被压扁或留白。如果你希望所有组共享相同的通路轴(比如只关注几个感兴趣的共同通路),就改成scales = "free"或直接去掉这行。- 因子顺序处理是ggplot2里最容易翻车的一步。很多人上来直接
factor(Description),结果纵轴按字母序排列,逻辑完全不对。富集图通常希望最显著的通路排在最上方,所以我在第3步先计算了组内p.adjust排序,再用这个顺序设置因子水平。
3.3 报错排查:在线环境里最常见的三种翻车现场
把绘图的坑挑几个高频率的说一下,希望你别再踩。
第一个报错是Error: Insufficient values in manual scale。这通常是你手动设置了颜色或者大小范围,但数据里存在NA值。解决办法很简单:先跑sum(is.na(df$p.adjust)),如果有NA,用df <- df[!is.na(df$p.adjust), ]过滤掉。出现NA的原因大多是Excel里把极小值显示成了科学计数法后复制粘贴时丢失了小数部分。
第二个问题不是报错,而是图出来以后纵轴乱序。根本原因就是因子水平顺序不是按组内显著性排的。我上面代码里已经把顺序逻辑写进去了,但如果你希望“每组各取Top10”,需要先在合并表之前就对每组单独排序筛选。这一步最好在Excel里完成,或者用R的dplyr包分组建top_n,比在ggplot里硬调聪明得多。
第三个问题是中文乱码。如果你把Description写成了中文,在云端RStudio里经常出现豆腐块。这不是代码问题,而是字体缺失。最省事的方案是Description全部用英文,中文含义放在论文正文里解释。如果导师要求图中显示中文,你需要额外配置中文字体文件,复杂度上升不少,并不建议在在线环境里做。
4. 单细胞Marker基因矩阵气泡图:DotPlot的底层逻辑与数据提取
4.1 Seurat的DotPlot到底在算什么
单细胞marker基因气泡图最常见的来源是Seurat。很多人只记住了DotPlot(seu, features = markers)这行函数,并不清楚它内部生成了什么矩阵,结果一旦离开Seurat对象,比如想用一个在线工具画图,就完全不会了。
实际上DotPlot只是两步计算的包装:
- 第一步,对每个基因、每个细胞亚群计算平均表达量(avg.exp),实际是expm1(mean(x))处理后的均值,也就是先对表达值做log1p逆变换再求平均,避免极少数高表达细胞拉高整体均值。
- 第二步,计算每个基因在每个细胞亚群中的表达阳性比例(pct.exp),阈值是表达值大于0的细胞比例,Seurat默认pct.exp用的是log1p后的表达矩阵里大于0的比例。
我们最终看到的图,气泡大小映射的是pct.exp,颜色映射的是avg.exp。也就是说,一个气泡同时承载两个维度的信息:这个基因在这个亚群里有多少细胞在表达(阳性比例),以及这些细胞的平均表达水平有多高(颜色深浅)。
理解这一点对你的数据整理能力提升非常大。因为如果你想把单细胞图也拖到在线工具里去画,根本不需要上传整个Seurat对象,只需导出一个四列表——基因、细胞类型、平均表达量、阳性比例——就足够了。文件从几百MB缩小到几十KB,任何在线平台都能轻松接收。
4.2 如何从Seurat对象导出可直接上传的绘图矩阵
下面给一段在本地R里提取矩阵的代码。你只需要在本地跑一次,把结果存成CSV,之后就再也不用碰R了,后续所有微调都丢给在线绘图环境或者自己熟悉的工具。
library(Seurat) # seu是已经完成聚类和细胞类型注释的Seurat对象 # markers是你选定的marker基因列表,按你想展示的顺序排列 markers <- c("CD3D", "CD3E", "CD79A", "MS4A1", "LYZ", "FCGR3A", "NKG7") # 用Seurat内置函数直接提取绘图数据 plot_data <- DotPlot(seu, features = markers, group.by = "celltype")$data # 此时的plot_data包含四列:avg.exp, pct.exp, features.plot, id # 列名不太直观,建议重命名后导出 colnames(plot_data) <- c("avg_exp", "pct_exp", "gene", "cluster") # pct.exp在旧版本Seurat中可能是0~1的小数,建议转成0~100的百分比,便于理解 plot_data$pct_exp <- plot_data$pct_exp * 100 write.csv(plot_data, "dotplot_matrix.csv", row.names = FALSE)这段代码的价值在于:它把Seurat的复杂对象缩减成一个四列矩阵,后续一切操作都不再依赖Seurat。如果你在本地连Seurat都没装,还可以手动从表达矩阵算。思路是:对每个细胞亚群取平均,算出平均值矩阵;再对每个亚群计算表达大于0的细胞比例,按同样的行列结构得到比例矩阵。两个矩阵分别堆叠成长表后,合并起来就得到了同样的四列。
我见过一些在线工具要求输入“基因×亚群”的宽表矩阵而不是四列长表。如果遇到这种情况,你可以在Excel里用数据透视表或者R的pivot_wider转换一下。把gene放行、cluster放列、avg_exp或pct_exp作为值,分别导出一份颜色矩阵和一份大小矩阵即可。
4.3 用ggplot2复刻DotPlot,在线环境也能画出满意的图
拿到四列长表后,剩余的事情就简单多了。即使你没有现成的在线R环境,能跑R的云端或者本地环境都适用。下面是完整绘制代码。
library(ggplot2) # 读取上一步导出的矩阵 df <- read.csv("dotplot_matrix.csv", header = TRUE, stringsAsFactors = FALSE) # 固定基因顺序和细胞类型顺序 df$gene <- factor(df$gene, levels = rev(c("CD3D", "CD3E", "CD79A", "MS4A1", "LYZ", "FCGR3A", "NKG7"))) df$cluster <- factor(df$cluster, levels = c("CD4 T", "CD8 T", "B cell", "Monocyte", "NK")) p <- ggplot(df, aes(x = cluster, y = gene)) + geom_point(aes(size = pct_exp, color = avg_exp)) + scale_color_gradient(low = "#F5F5F5", high = "#C2185B") + scale_size_continuous(range = c(1, 10)) + theme_bw(base_size = 12) + theme( axis.text.x = element_text(angle = 45, hjust = 1), panel.grid.major = element_blank(), legend.position = "right" ) + labs(x = NULL, y = NULL, size = "Percent Expressed", color = "Average Expression")这个复刻版有一个比Seurat原生DotPlot更友好的地方:你可以自由调整点的大小映射范围、颜色渐变方向,甚至可以把细胞类型放到纵轴、基因放横轴,这在展示大量marker时会更方便排版。
一个值得注意的细节是纵轴基因顺序。我用了rev()倒转基因列表顺序,这样在图上第一个基因会出现在顶部,与你选定marker时的阅读顺序一致。如果不做这步,基因会从底部往上排,很多人第一次画出来都会觉得“顺序怎么反了”。
4.4 这张图最常见的三个改稿问题
我审过不少人的单细胞marker气泡图,问题集中在三个方面。
第一个是颜色深浅区分度不够。Seurat默认色板偏浅,打印出来以后低表达和高表达之间差别很模糊。我的建议是低端选浅灰白,高端选酒红或深紫,并且在Figure里加上“表达量低的亚群”那部分说明。如果数据里本身没有高表达的基因-亚群组合,整张图会偏淡,这时可以通过limits参数把色标下限抬高,或者换离散色板,效果会立刻改观。
第二个是气泡大小与颜色信息重复造成误解。有的基因在一个亚群里avg.exp很高,但pct.exp很低(少数细胞极高表达),这时图上会出现一个又大又深的点,读者容易误以为该亚群普遍高表达。要规避这个,你不要在图上标注绝对阈值,而是在方法学里说明“气泡大小反映阳性比例”,并在正文解读时特别注意这类基因-亚群组合。
第三个是marker基因顺序的“逻辑”问题。推荐的排列方式不是随便列,而是按细胞类型把marker分组排列:比如T cell相关marker放一起、B cell marker放一起,这样读者一眼就能看到整个矩阵的对角块。你可以在导出数据前手动整理markers向量,代码里那种硬编码的方式就是为此设计的。
5. 三条在线路径怎么选:在线R、Shiny应用与公共平台对比
最后聊一下工具选型。既然你已经知道了两种图的底层数据和代码逻辑,那选哪条路径完全取决于你的动手意愿和分析频率。我按三类人群分别说明。
| 方案 | 典型工具 | 适合人群 | 优点 | 短板 |
|---|---|---|---|---|
| 云端RStudio | Posit Cloud等 | 愿意写代码、希望成图风格可控的人 | 可复现性强、图表定制空间大、免费额度够用 | 有学习曲线,需要熟悉R基础 |
| 现成Shiny应用 | 一些实验室或工具站开放的绘图应用 | 零R基础、想快速出图看效果的人 | 上手快,上传数据点两下就出图 | 定制度有限,格式未必符合期刊要求 |
| 公共在线分析平台 | 单细胞数据门户、富集分析在线服务 | 整体分析场景,不只是画一张图 | 功能全面,从分析到作图一体化 | 数据需上传,有时格式限制严格,且不太适合自定义预览 |
如果你是零基础,只想快速看趋势,选第二条路。但务必提前把数据整理成前面讲的四列或七列格式,否则任何工具都帮不了你。如果你接下来还有多组比较、换色板、调分辨率等个性化需求,选第一条路,一次性投资学习成本,之后能反复用。第三条路适合你本身就要做整套在线分析、不想在本机装任何生物信息学工具的情况。
以我自己的经验来说,混合搭配是最好的:数据分析阶段用R把矩阵导成长表,绘图阶段在云端RStudio里跑固定模板。这样既不用本地装环境,又保留了随时改图的能力。很多线上工具适合“第一次出图”,但论文返修时往往需要改配色、改字号、改分面方式,那时没有代码在手,只能从头再来。
6. 我自己踩过几次坑之后的一点点体会
说个真实经历。我最早画多组富集气泡图时,直接在本地把三组数据合并成大表,用ggplot2画完后看起来不错,但投出去审稿人提了一个问题:三个比较组的纵轴通路数量差异太大,组1有30条通路,组3只有5条,导致组3的条带特别宽,点被拉得很大,视觉上很夸张。后来我才意识到,问题不在绘图代码,而在数据处理:应该每组先按p.adjust取Top10,再合并绘图。这个“TopN截取”的习惯,后来我每次做多组气泡图都会保留,它能让你的图清爽很多。
单细胞marker气泡图这边,我最大的教训是导出CSV时没有检查重复行。如果一个基因在两个亚群的avg.exp和pct.exp完全一样,ggplot会重叠绘制两个同一个位置的点,稀释颜色饱和度,视觉上看起来像颜色变淡了。这种情况虽然少,但一旦出现就很隐蔽。最简单的排查方式是在导出前跑一下nrow(unique(plot_data)),看和原始行数是否相等。
最后分享一个小技巧:无论多组富集图还是单细胞DotPlot,我都建议把最终出图的PDF和PNG同时保存。PNG方便日常查看和插入PPT,PDF则用来投稿。很多在线工具只能输出PNG,一旦期刊要求矢量图,就非常被动。用R的话,ggsave一句就能同时输出两种格式,这也是我坚持走代码路线的重要原因。希望这篇内容能帮你少走一段弯路,把时间花在解读结果上,而不是耗在环境和报错里。