☰
R语言绘制基因星座图:共现与互斥关系可视化全流程
2026/10/16 12:26:08 网站建设 项目流程

先说明一个容易撞车的点:这里聊的“星座图”不是通信工程里的 I/Q 相位星座图,而是生物医学论文里很常见的那种基因关系展示图。它经常出现在肿瘤基因组学文章里,用来同时回答两个问题:哪些基因喜欢在同一个样本里一起突变,哪些基因几乎不会同时突变。图面上每个点代表一个基因或特征,点越大说明在越多样本里被检出;点与点之间的连线代表统计显著的“共现”或“互斥”。我第一次在顶刊里看到这张图时,第一反应是“这个图好看,我要复现”,真正动手才发现,好看只是结果,难点反而在数据矩阵、两两检验和布局微调上。这篇文章把我整个过程拆开写出来,包括代码、参数、配色和踩过的坑,供做组学数据可视化的人参考。

1. 星座图不是“画点连线”:先搞清楚它到底在回答什么问题

1.1 共现与互斥:两个基因在样本里算不算有“缘分”

做肿瘤样本体细胞突变分析时,我们通常把数据整理成一张矩阵:行是样本,列是基因,单元格只有 0 或 1。0 表示这个样本里没检测到该基因突变,1 表示有。基于这张矩阵,任意两个基因都能组成一个 2×2 的列联表。

假设我们有 100 个样本,观察基因 A 和基因 B 的突变情况:

A 突变A 未突变
B 突变105
B 未突变580

这种情况下,A 与 B 同时突变的样本有 10 个,单独突变各 5 个,绝大多数样本两个基因都没有突变。用优势比算一下:(10×80)/(5×5)=32,远大于 1,说明 A 和 B 在样本里明显“抱团出现”,这就是共现。

反过来,如果 100 个样本里两者同时突变只有 1 个,A 单独突变 50 个,B 单独突变 49 个,两个都没有的只有 0 个,那 OR 值会远小于 1。生物学上,这种互斥模式常被解读为两个基因可能属于同一条信号通路,一个坏了就足以推动细胞癌变,没必要“双保险”地同时坏。这只是最常见的解读,具体机制要结合实验,但图的做法是一样的。

星座图就是把所有基因对的关系放在同一张图里:点代表基因,连线代表显著关系,线的颜色和虚实代表共现还是互斥,线的粗细代表关系的强度。和普通网络图相比,它多了一个“统计检验”的前置步骤,不是任何有连线的基因对都能画出来,必须经过显著性筛选。

1.2 为什么它看起来像“星座”,而不是乱成一团的网络图

真正的星座图在数学上和带权网络图没有本质区别,但它之所以看起来像夜空星座,主要是因为布局算法。基因与基因之间存在显著关系的会被力导向布局拉到一起,形成若干个小簇,相当于夜空里的“星座”分组;没有关系的基因距离远,甚至不连边。图上的点有大有小,有点像亮度不同的星星;线的长短不完全代表实际关系,更多是布局结果。

这种展示方式在二三十个基因时最好用。如果基因数量膨胀到上百个,两两检验的数量会变成几千甚至上万,很容易画成一张蜘蛛网。所以,后面会说怎么筛边、怎么控制图的复杂度。

2. 动手前先把统计检验这关过了:输入矩阵、两两比较、边筛选

2.1 输入数据只需一张很朴素的表,但格式必须是样本-基因

我这里用的例子模拟一批体细胞突变数据,结构是两列:第一列样本编号,第二列基因名称。真实项目里可能还有突变类型、VAF、染色体重区等信息,但做星座图最底层的输入就是“这个样本有没有这个基因的突变”。

library(tidyverse) library(igraph) library(ggrepel) # 模拟数据,正式使用时替换成自己的文件 mut <- read_tsv("somatic_mutations.tsv", col_names = c("sample", "gene")) # 转成宽矩阵:行样本,列基因,0/1表示突变与否 mat_bin <- mut %>% distinct(sample, gene) %>% mutate(value = 1) %>% pivot_wider(id_cols = sample, names_from = gene, values_from = value, values_fill = 0) %>% column_to_rownames("sample") %>% as.matrix()

这里我刻意把所有基因名换成 geneA、geneB 这种占位符,你在自己项目里替换成真实基因名即可。矩阵中同一个样本同一基因只能有 0/1 两个状态,这是基因水平存在/缺失的常规处理方式。如果想区分不同突变位点,或者考虑突变克隆比例,那需要单独设计权重,星座图就不一定合适了。

2.2 两两基因检验:2×2 表、优势比与多重比较校正

矩阵准备好之后,每个基因对都相当于一个四格表。最稳妥的检验方式是“精确检验”,因为它不依赖大样本近似。样本量只有几十个的时候,卡方检验很容易出现预期频数过低,结果不靠谱。精确检验虽然慢一点,但两两检验通常只有数百到数千对,计算量完全能接受。

pair_test <- function(mat) { genes <- colnames(mat) out <- list() k <- 1 for (i in seq_len(ncol(mat) - 1)) { for (j in (i + 1):ncol(mat)) { a <- mat[, i] b <- mat[, j] both <- sum(a == 1 & b == 1) only_a <- sum(a == 1 & b == 0) only_b <- sum(a == 0 & b == 1) neither <- sum(a == 0 & b == 0) tab <- matrix(c(both, only_a, only_b, neither), nrow = 2) # 出现 0 时做 Haldane 修正:四个格子都加 0.5 tab_fix <- tab + 0.5 * any(tab == 0) p_val <- fisher.test(tab_fix)$p.value or_val <- (tab_fix[1, 1] * tab_fix[2, 2]) / (tab_fix[1, 2] * tab_fix[2, 1]) out[[k]] <- tibble(gene1 = genes[i], gene2 = genes[j], both = both, or = or_val, p = p_val) k <- k + 1 } } bind_rows(out) } res <- pair_test(mat_bin)

几万个精确检验跑下来,多少会有点慢。如果基因数超过 200,强烈建议先按检出样本数过滤,比如只保留在至少 5 个样本中检出的基因;否则结果里大量基因对都没有信息量,还要白白承担多重比较校正的压力。

检验出来的 P 值不能直接用。几十个基因两两比较就有成百上千次检验,总会有假阳性。常规做法是用 BH 方法做 FDR 校正,然后同时卡两个条件:校正后 P 值小于 0.05,且abs(log2(OR)) >= 1,也就是 OR 大于 2 或小于 0.5。这一步能把那些“有统计学意义但生物学上弱得没法看”的边去掉。

res_sig <- res %>% mutate(padj = p.adjust(p, method = "BH")) %>% filter(padj < 0.05, abs(log2(or)) >= 1) %>% mutate(relation = ifelse(log2(or) > 0, "共现", "互斥"))

实测中我最常卡的是abs(log2(OR)) >= 1这一条,它比单纯卡 P 值更能控制边数。P 值对样本量非常敏感,样本一多,OR=1.2 也可能显著,但画在图上就是一条细线,读者看不出价值。

2.3 边的颜色、粗细和线型的编码规范

比较常见的编码方式是:暖色实线代表共现,冷色虚线代表互斥;线越粗代表显著性越强。用-log10(padj)作为边的权重,然后缩放到 0.2 到 1.5 的线宽范围。这样既保留了“越显著越突出”的直觉,又不会出现一条比点还粗的夸张线段。

如果期刊要求灰度印刷,线型比颜色更可靠。黑白打印时红色和蓝色很难区分,但实线和虚线一定分得开。所以我会把linetype也映射到共现/互斥这个变量,颜色和线型同时编码,形成冗余信息。

3. R 语言复现完整过程:从 igraph 布局到 ggplot2 逐层绘制

3.1 用 igraph 生成网络对象,再用力导向布局得到坐标

先基于筛选后的边表构造图对象,用力导向布局给节点安排位置。力导向布局的思路是模拟物理斥力和引力:有关联的节点互相靠近,没有关联的节点互相排斥。基因数量少的时候随便跑都能出效果,数量多就得多跑几轮迭代,并且固定随机种子保证每次结果一致。

g <- graph_from_data_frame(res_sig[, c("gene1", "gene2")], directed = FALSE) set.seed(2024) coords <- layout_with_fr(g, niter = 1000) rownames(coords) <- V(g)$name node_count <- sort(colSums(mat_bin)) node_df <- tibble(gene = V(g)$name) %>% mutate(count = as.integer(node_count[gene]))

这里先提醒一句:只有出现在显著边里的基因才会出现在图上。如果一个基因突变频率很高,但与其他所有基因都不显著共现或互斥,它就是一个孤立节点。要不要保留孤立节点,取决于你的目的。想展示完整景观,就保留;想突出关系结构,就删掉。后面第 5 节再展开说。

3.2 用 ggplot2 画节点、连线和标签

拿到布局坐标后,我基本不再用plot.igraph直接出图,而是把坐标转成长表,用 ggplot2 逐层画。原因很简单:igraph 的基础绘图函数适合快速查看,但要精确控制图例、字体、配色、嵌入尺寸,ggplot2 更顺手。

node_map <- node_df %>% mutate(x = coords[gene, 1], y = coords[gene, 2]) edge_df <- res_sig %>% left_join(node_map, by = c("gene1" = "gene")) %>% rename(x = x, y = y) %>% left_join(node_map, by = c("gene2" = "gene")) %>% rename(xend = x, yend = y) %>% mutate(strength = -log10(padj), strength = strength / max(strength)) # 给节点补一个功能通路分类,用于填色 node_df <- node_df %>% left_join(tibble(gene = c("geneA", "geneB", "geneC", "geneD", "geneE", "geneF"), pathway = c("通路一", "通路一", "通路二", "通路二", "通路三", "通路三")), by = "gene")

绘制时注意geom_segment的线宽映射,新版 ggplot2 推荐用linewidth,旧版还是size。节点我用shape = 21这种带描边的空心圆,方便在填色之外再加一层灰色描边,避免浅色节点直接融进底色里。

p <- ggplot() + geom_segment(data = edge_df, aes(x = x, y = y, xend = xend, yend = yend, color = relation, linewidth = strength), alpha = 0.65) + geom_point(data = node_df, aes(x = x, y = y, size = count, fill = pathway), shape = 21, stroke = 0.3, colour = "grey30") + ggrepel::geom_text_repel(data = node_df, aes(x = x, y = y, label = gene), size = 2.5, max.overlaps = 15, seed = 1) + scale_size_area(max_size = 10) + scale_linewidth(range = c(0.2, 1.5)) + scale_color_manual(values = c("共现" = "#B5463F", "互斥" = "#3F6EA5")) + scale_fill_manual(values = c("通路一" = "#F4A582", "通路二" = "#92C5DE", "通路三" = "#D1A7C8")) + labs(color = "关系", linewidth = "显著性强度", fill = "通路", size = "检出样本数") + theme_void() + theme(legend.position = "bottom", legend.title = element_text(size = 8), legend.text = element_text(size = 7))

theme_void()会直接干掉坐标轴、网格线和背景,星座图这种“星空感”的图用空白背景最合适。只要不涉及地理投影或实际坐标,就不需要保留坐标轴。

3.3 标签防重叠与手动微调

ggrepel的自动防重叠不是万能的。基因名一多,它会把标签推到图片边缘,甚至把两个标签推到同一侧互相打架。我常用的三个办法:

  • 调大max.overlaps参数,允许更多标签密集分布。
  • 减小字号,图里标签只负责定位,真正的基因列表放图注里。
  • 对重点基因手动指定位置。

手动调整的方法是复制一份节点坐标数据,把重点基因的 x、y 在本地改好,再传给geom_text,不让它参与自动避让。这个方法看起来很笨,但最后出图效果最可控。

4. 从“能画出来”到“像顶刊配图”:排版、配色、字体和出图参数

4.1 配色的核心不是“好看”,而是色盲友好与灰度打印兼容

我整理了一个常用配色表,基本能应付大多数星座图需求:

元素推荐配色说明
背景纯白或纯黑顶刊大多数场景用白底
共现边深红色 #B5463F暖色,代表正相关
互斥边深蓝色 #3F6EA5冷色,代表负相关
节点填充低饱和色板避免纯红纯蓝大面积铺开
节点描边灰色 #8C8C8C让节点和边在视觉上分层
字体无衬线字体与图内线条风格统一

如果节点数量不多,我建议节点填充只做“通路分类”,不要每个基因一个颜色。基因一多就二十种颜色,图例比图还大,审稿人看着会很累。通路信息如果和边的共现/互斥语义重叠,填色也可能误导读者,这时宁可不用填色,用深浅灰色梯度表示突变频率。

4.2 画布比例、图例位置和字号

出图前先想好图在文章里的位置。单栏图通常宽 85 mm,双栏图宽 180 mm。星座图适合正方形或接近正方形的画布,比例太扁会把边拉得东倒西歪。

我常用的出图参数:

ggsave("constellation_plot.pdf", p, width = 180, height = 160, units = "mm", device = cairo_pdf)

图例放底部是安全选择,放右侧容易把画布挤变形。图内文字能小就小,图注里再放大说明。期刊排版后图会缩放到固定宽度,字太大显得业余,字太小打印出来看不清。经验值是最终印刷宽度下,图内文字高度在 5~7 pt 之间。

4.3 中文字体问题和 PDF 字体嵌入

星座图标签大多是英文基因名,不涉及中文字体。但图例如果有“共现”“互斥”这种中文,Windows 下直接用 cairo_pdf 偶尔会显示不对。最顺手的方案是引入showtext,把中文字体包进来再画。

library(showtext) font_add("Arial", regular = "/path/to/Arial.ttf") showtext_auto()

showtext_auto()开启后,ggplot2 的文本渲染会走 showtext 管道,导出的 PDF 内置字体轮廓,换电脑打开不会发虚或者变方块。如果不需要中文,直接用cairo_pdf一般就够了,字体也会正常嵌入。

5. 真实复现时踩过的那些坑:边爆炸、孤立点、字体变形

5.1 边数爆炸:每增加一个基因,边是平方级增长

这是星座图复现里最大的坑。假设有 80 个基因,候选基因对是 3160 个;卡完 P 值和 OR 值,可能还剩几百条显著边。全画上去,图会变成一团毛线。我的解决办法是分两步:先用严格的 FDR 和 OR 阈值过滤,再加入“只保留显著性最强的前 50 条边”的硬上限。

res_sig <- res_sig %>% arrange(padj) %>% slice_head(n = 50)

注意这个操作只删边,不删节点。只要某个基因在保留的边里出现过,它就会出现在图上。如果你发现某个核心基因因为这条规则被删得只剩一条边,那也没办法,图需要可读性。硬筛选的代价丢掉的次要关系可以在附表里披露。

5.2 孤立节点和“掉渣”的小簇

力导向布局对孤立节点非常不友好。一个节点如果没有任何显著边,它会像一块孤石一样飘在图中央或角落。几个孤立节点堆在一起,视觉上会让人误以为它们之间也存在关系。

我的处理方式是在绘图前先检查:

orphan <- setdiff(V(g)$name, unique(unlist(edge_df[, c("gene1", "gene2")])))

有孤立节点时,优先判断它是否值得保留。如果它突变频率极高,却不和任何基因显著共现/互斥,那它本身就是一个重要信息,可以在图旁边加一列点阵图,而不是硬塞进星座图。如果只是偶然进入候选列表,直接删掉更清爽。

5.3 坐标长表里的线宽、点大小和美观细节

画图时最容易忽视的是“点大小”的度量方式。ggplot2 默认size映射的是点的直径,而读者阅读“气泡大小”时默认感知面积。两组不同大小的点,直径翻倍会让面积变成四倍,看起来比实际夸张得多。星座图这种点大小对应样本数的图,我会用scale_size_area(),让点的面积正比于数值,而不是直径正比于数值。

线宽也一样,不建议直接用-log10(padj)原始值,最好先归一化到 0.2~1.5 之间,否则某个 P 值极小(比如 1e-30)的边会变成一条黑色粗带,破坏整体平衡。

5.4 PDF 导出后的字体发虚

我在这里吃过一次亏:本地 RStudio 里预览图很正常,导出 PDF 后用阅读器打开,所有文字糊成一片,最后发现是没有走 cairo 渲染。R 默认的 PDF 设备是pdf(),在 Windows 上对字体支持不太好。解决方案就是前面说的:

ggsave("constellation_plot.pdf", p, width = 180, height = 160, units = "mm", device = cairo_pdf)

如果你的系统装不上 cairo,退一步用device = "pdf"也能出图,但要另做字体嵌入处理。最省心的还是直接输出 300 dpi 的 TIFF 或 PNG,用于投稿系统时再转 PDF。

6. 星座图思路的延伸:不是只有癌症突变才能这么画

6.1 从体细胞突变扩展到病毒基因组与微生物组

星座图本质上只要求一个二元事件矩阵,不关心事件本身是什么。病毒变异数据里可以看哪些位点的变异在患者体内共同出现;微生物组数据里可以把 OTU/ASV 的“有/无”当成事件,看哪些物种倾向于在同一个样本里出现或互斥。只要矩阵是样本×特征的 0/1 结构,统计流程几乎可以原样搬过去。

这种跨场景复用一个画法的价值在于,审稿人看多了“热图+火山图+箱线图”的固定组合,突然出现一张结构清晰的星座图,会比较容易记住你的结果。但也要诚实评估:星座图展示的是二元关联,不包含方向和因果关系。如果只想展示两个变量之间的强度,用相关系数热图可能更直接。

6.2 给星座图增加分面、分组和布局封印

当数据里有明确的组织类型或疾病亚型时,可以按亚型分面,画出多个小星座图。每个小图只包含该亚型中显著的基因关系,这种对比方式比把所有亚型压缩到一张图里更清晰。缺点是版面占用大,适合补充材料。

如果节点属于多个通路,布局时也可以把通路作为“组”的约束:同一通路的基因在初始坐标上放在相近位置,再用力导向布局做局部优化。这样画出来的图更有叙事感,适合强调“通路内部互斥、通路之间共现”的结论。

6.3 不是所有关系都适合星座图

最后说一个选题层面的建议:如果基因之间的显著关系非常密集,比如超过 100 个节点、上千条边,那星座图大概率不适用。我见过有人硬把 200 个基因画成星座图,结果就是一团彩色毛线,审稿意见里必然会有“illegible”。这种情况不如退回热图或 upset 图,先把“哪些基因在一起”的问题讲清楚,再做可视化装饰。

我在实际项目里养成的一个习惯是:画星座图之前,先把所有基因对检验结果输出成一个 CSV 文件,包含基因名、样本数、共现次数、OR、P 值、FDR、关系方向这些字段。图只是这张表的一种视觉翻译,审稿人问起具体数据和筛选规则时,直接给他表,比改代码快得多。星座图很容易给人一种“分析很复杂”的感觉,但真正经得起问的,永远是图背后那张可以复算的统计表。

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

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

立即咨询