1. 先从一张图看明白:NC上的分区热图到底在画什么
复现Nature Communications级别的图表,大多数人的第一反应是找代码、跑脚本。我的建议恰恰相反:先把图"拆"到不能再拆为止。分区热图(split heatmap)在NC里出现频率极高,尤其是转录组、蛋白组、单细胞功能模块相关的论文里,几乎每两三篇就能看到一张。这种图第一眼看过去很唬人,结构复杂,颜色丰富,左侧还有一堆色条,但真正拆开之后你会发现,它本质上就是三层信息的叠加:一个主热图、一组行分区、一组列分区,再加上每个分区对应的注释条。
1.1 一张图里至少叠了四层信息
以一篇典型的NC基因表达谱图为例,你能看到的东西通常包括:
- 主热图区域:每行是一个基因或一个蛋白,每列是一个样本或一种处理条件,格子颜色表示表达量的相对高低,常见配色为蓝-白-红或紫-黑-黄。
- 行方向的分割线:热图沿行方向被分成几个明显的"块",每个块代表一个基因模块、通路或聚类类群,块与块之间有空隙或色条隔开。
- 列方向的分割线:样本在列方向上也被分隔,通常是对照组、处理组、时间点等不同分组。
- 顶部或左侧的注释条:用纯色色块标注每个样本属于哪个实验分组、每个基因属于哪个模块,有时还会叠加临床指标、样本类型等更多层注释。
换句话说,分区热图不是单纯"把热图画出来",而是把聚类结果、分组信息和表达量同时映射到一张图里。复现的难点也在这里:绘图函数只是最后一公里,前面百分之八十的功夫都在组织数据。
1.2 为什么要分区:不区分的聚类热图只算半成品
很多人用R画热图,第一反应是pheatmap或者seaborn的clustermap,直接扔一个表达矩阵进去,自动聚类、自动出图,看起来很省事。但如果你拿这张图去对标NC的排版要求,会立刻发现两个致命问题:第一,自动聚类把整张图当成一个整体,行和列的顺序完全由树状图决定,你很难让"所有对照组样本排在左边,所有处理组排在右边";第二,没有分区就没有"模块感",读者只能看到一大片密密麻麻的颜色,看不出哪些基因属于同一个功能模块、哪些基因跟随同一处理条件变化。
分区热图的核心价值,是用"人为定义的分组"来引导视觉重点。行分区告诉你"这批基因共同参与某条通路",列分区告诉你"这批样本属于某个实验状态"。正因如此,复现NC图表的第一课,不是学ComplexHeatmap的语法,而是学会设计你的分组方案。
2. 数据准备:分区热图的核心不是代码,是分组方案
我有一个很深的体会:凡是复现失败的项目,百分之九十不是绘图代码写错了,而是数据表结构不对。ComplexHeatmap对数据的要求其实极其简单:一个数值矩阵,加一个和你分组信息对应的因子向量。但恰恰是这个"对应关系",特别容易翻车。
2.1 行与列各需要一张"分组注释表"
在动手写代码之前,我会先把项目的数据整理成三张表:
- 表达矩阵:行是基因/蛋白/OTU等特征,列是样本。矩阵里的值可以是原始表达量、标准化后的CPM/TPM、log2倍数变化或z-score,取决于你要表现什么。
- 行分组表:第一列是特征名,第二列是该特征所属的模块、通路或聚类类群。这张表的顺序不需要和矩阵行顺序一致,但特征名必须能准确匹配矩阵的行名。
- 列分组表:第一列是样本名,第二列是样本所属的实验分组,比如Control、Treatment A、Treatment B。同样,匹配靠样本名而不是靠位置。
为什么强调这三张表要分开维护?因为我见过太多人把分组信息塞进矩阵的列名里,比如"Sample01_Control",然后画图前再用字符串切割去提取分组。这么做短期能跑通,但只要你的样本编号一复杂,或者某一组样本数量不一致,切割逻辑就会出错。我自己的习惯是:从数据清洗阶段就维护一张干净的metadata表,绘图时用left_join或match匹配到矩阵里,而不是在画图脚本里做字符串处理。
2.2 用z-score还是原始值:期刊默认的两种配色逻辑
分区热图的颜色语义有两种主流选择。第一种是直接展示表达量的原始值,比如CPM从0到500,颜色用连续渐变色,从浅到深。这种处理适合展示绝对量差异,但你会在图例上看到一个"0到500"的跨度,如果个别极高表达的基因存在,整张图都会被顶到最深色,低表达区域的差异完全看不出。
第二种是行方向z-score标准化,这也是NC里最主流的做法。计算公式很基础:对每个基因的所有样本,用该基因的均值和标准差做标准化,得到每个样本偏离该基因平均水平的程度。标准化之后,每个基因的均值是0,标准差是1,正负号表示相对于自身平均水平的升高或降低。这样做的最大好处是,高表达基因和低表达基因可以在同一张图里公平比较"变化幅度",而不是比较"绝对量"。
需要注意,R的scale默认对列做标准化,也就是对每个样本的所有基因做标准化,这跟我们要的"按基因标准化"方向恰好相反。所以正确的写法是:
mat_z <- t(scale(t(mat)))这个写法我每次都会在代码里加注释,因为太容易被忽视。先转置让行变成列,scale之后每列是一个基因,再转置回来,得到的矩阵就是每一行一个基因、行均值为0、行标准差为1的z-score矩阵。
2.3 模拟一套完整的复现数据
在没有真实实验数据的情况下,我会构造一份尽可能贴近真实场景的模拟数据来演示完整流程。假设我们有100个基因、24个样本,样本分为三个处理组,每个组8个样本;同时100个基因被先验地归入四个表达模块。为了让演示效果明显,我给不同模块设定了不同的处理响应模式:
library(ComplexHeatmap) library(circlize) set.seed(20240401) n_genes <- 100 n_samples <- 24 row_group <- rep(c("Module A", "Module B", "Module C", "Module D"), each = 25) col_group <- rep(c("Control", "Treat_A", "Treat_B"), each = 8) base <- matrix(rnorm(n_genes * n_samples, mean = 0, sd = 1), nrow = n_genes) effect <- matrix(0, nrow = n_genes, ncol = n_samples) effect[row_group == "Module A", col_group == "Treat_A"] <- runif(25 * 8, 1.2, 2.5) effect[row_group == "Module B", col_group == "Treat_B"] <- runif(25 * 8, -2.2, -1.0) effect[row_group == "Module C", col_group == "Treat_A"] <- runif(25 * 8, -1.8, -0.8) mat <- base + effect rownames(mat) <- paste0("Gene", sprintf("%03d", 1:n_genes)) colnames(mat) <- paste0("Sample", sprintf("%02d", 1:n_samples)) mat_z <- t(scale(t(mat)))这份模拟数据的逻辑很直白:Module A在Treat_A处理下整体上调,Module B在Treat_B处理下整体下调,Module C在Treat_A处理下也出现了下调,Module D则基本是纯噪声。这个结构保证了你画出来的分区热图会有清晰的模块响应模式,而不是一整片均匀的噪点。
数据备好之后,先别急着画图。我强烈建议把mat_z的维度、row_group的长度、col_group的长度分别检查一遍,确认三者一致。用identical(rownames(mat_z), names(row_group))这类语句卡一下,能避免后面百分之八十的错位问题。
3. 用ComplexHeatmap实现分区与注释条
选ComplexHeatmap而不是pheatmap,最大的理由是它原生支持split分区、多层annotation、精细控制每个图形元素。pheatmap也能做column annotation和row annotation,但一旦涉及分区热图里"每块内部重新聚类""不同分区用不同颜色""注释条与主图例分离"这些需求,pheatmap就力不从心了。
3.1 一条Heatmap命令完成行列双分区
ComplexHeatmap的分区核心参数是row_split和column_split。你不需要自己预先切割矩阵,只需要告诉它按什么因子来切,它会在每个分区内部重新做聚类。这是我个人认为它和pheatmap最大的分水岭。
col_fun <- colorRamp2(c(-2, 0, 2), c("#2166AC", "white", "#B2182B")) group_col <- c("Control" = "#4DAF4A", "Treat_A" = "#FF7F00", "Treat_B" = "#984EA3") module_col <- c("Module A" = "#66C2A5", "Module B" = "#FC8D62", "Module C" = "#8DA0CB", "Module D" = "#E78AC3") ht <- Heatmap( mat_z, name = "Z-score", col = col_fun, cluster_rows = TRUE, show_row_dend = TRUE, row_split = factor(row_group, levels = c("Module A", "Module B", "Module C", "Module D")), row_title = "Module", cluster_columns = TRUE, show_column_dend = TRUE, column_split = factor(col_group, levels = c("Control", "Treat_A", "Treat_B")), column_title = "Treatment", top_annotation = HeatmapAnnotation( Group = col_group, col = list(Group = group_col) ), left_annotation = rowAnnotation( Module = row_group, col = list(Module = module_col) ), show_row_names = FALSE, show_column_names = FALSE, row_gap = unit(3, "mm"), column_gap = unit(2, "mm"), border = TRUE ) draw(ht)这段代码里最关键的是row_split和column_split传入的因子。因子字符串顺序对应了数据的原始顺序,而levels决定了分区最终从上到下、从左到右的显示顺序。这里有个容易被忽略的坑:如果你不指定levels,R会按字母顺序排列因子水平,四个模块很可能变成Module A、Module B、Module C、Module D倒过来或者以字母序排序,跟你想展示的实验逻辑顺序不一致。所以在因子构造时就明确levels,是最省心的做法。
3.2 顶部注释条与左侧注释条的写法
HeatmapAnnotation和rowAnnotation是本图的"注释担当"。从代码上看,它们只是把一个命名向量传给col参数,但这里有三条实用经验。
第一,注释数据向量的顺序必须和矩阵的列顺序/行顺序完全对齐。比如top_annotation里写Group = col_group,ComplexHeatmap会按位置把col_group的第一个元素对应到矩阵的第一列。如果你的列顺序在数据处理过程中被调整过,比如做了order()或arrange(),而col_group没有同步调整,注释条的颜色就会错位,主热图和注释条会对不上。这个错位在代码里通常不会报错,只在成图上出现,非常隐蔽。
第二,col列表里的命名要和注释向量的值严格一致。比如列分组里有"Treat_A",你在group_col里也必须写"Treat_A" = "某个颜色",不能写"TreatA"或"Treatment_A"。这里没有模糊匹配,差一个字符都会导致报错或整组变成默认灰色。
第三,如果你的列分组不止一个维度,比如除了处理组还想加一个批次信息,直接在HeatmapAnnotation里增加第二个命名向量即可:
top_annotation = HeatmapAnnotation( Group = col_group, Batch = batch_info, col = list(Group = group_col, Batch = batch_col) )多个注释条会依次堆叠在顶部,自动垂直排列,不需要你手动计算坐标。
3.3 聚类、排序与split参数配合的底层逻辑
理解了split,你就理解了分区热图的一半。很多人以为row_split的作用是"把矩阵按分组重新排序",其实不是。split只是画了一堵墙:它不会破坏行之间的原始顺序,也不会自动把同一组的行搬到一起。如果你直接把一个乱序的矩阵丢给Heatmap,再传一个按名字匹配的row_group,你会发现分区是乱的——每个分区里的行散落在矩阵各处。
正确的做法有两种。第一种,在画图前就把矩阵的行按分组排好序,也就是让同组的行相邻;第二种,交给聚类去处理,因为cluster_rows = TRUE时,ComplexHeatmap会在每个分区内部单独聚类,同一分区的行在视觉上自然聚到一块。我推荐第二种,因为它同时解决了"分区"和"每个分区内部排序"两个问题,而且在每个分区内聚出来的顺序,往往比手工排序更有生物学意义,相似的表达模式会靠得更近。
列方向同理。column_split加上cluster_columns = TRUE后,ComplexHeatmap会在Control、Treat_A、Treat_B三个分区内部各自做列聚类。这意味着每组内的样本不会按原始样本编号排列,而是依据表达谱相似度重新排。NC期刊图非常喜欢这种排法,因为它让每一个处理组内部的相似样本靠在一起,视觉上更整洁,也暗示了组内生物学重复性。
如果你的数据没有做聚类却被强行设置cluster_rows = TRUE + row_split,只要矩阵行数不是太大,一般不会出问题。但如果你想要完全固定的行顺序,比如按某个通路基因列表从头到尾排,那么应该设置cluster_rows = FALSE,再通过row_order参数传入你指定的顺序。这个参数在复现文献图时经常用到,因为期刊的Supplementary里通常会给出具体的基因列表顺序。
4. 期刊级微调:颜色、图例、字体和布局
代码跑通、图上出来,只完成了百分之五十。NC图的质感不会来自花哨的配色,而是来自克制的细节:颜色断点是否合理、图例是否清晰、字体是否统一、布局是否紧凑。这一节我拆开来讲。
4.1 色阶的截断与映射:为什么c(-2,0,2)是默认配置
colorRamp2(c(-2, 0, 2), c("#2166AC", "white", "#B2182B"))这段代码的含义是:把z-score矩阵中的-2映射为深蓝、0映射为白色、2映射为深红,中间值线性插值。很多人生搬硬套这个参数,却不知道为什么是-2和2。
z-score的本质是"偏离均值多少个标准差"。绝大多数基因的表达变化在-2到2之间已经能覆盖主要信息,超出±2的极端值通常是个别基因的特异性高表达,如果不对它们做截断,整张图会严重偏向红色,中低变化的区域全部变成淡色,完全看不出结构。所以c(-2,0,2)相当于告诉读者:"我只关心变化幅度在平均水平上下两个标准差以内的信息,超出部分统一显示为最深色。"
如果你不想用固定阈值,也可以用分位数做断点:
q_cut <- quantile(mat_z, c(0.02, 0.5, 0.98)) col_fun <- colorRamp2(q_cut, c("#2166AC", "white", "#B2182B"))这样颜色映射会自动适应每套数据的具体分布,但代价是不同图之间的色阶不可比。期刊图通常整篇统一用同一套阈值,让读者能在图与图之间直接比较颜色深度,所以固定c(-2,0,2)反而是更常见的做法。
4.2 图例与标题的细节控制
绘制多个annotation之后,你会在图的右侧看到两个图例:一个对应主热图颜色条(Z-score),另一个对应Group和Module的分组注释。默认状态下它们会挤在一起,NC的图例通常排版很宽松,每个图例单独成列,有明确的标题。
我习惯在draw的时候明确指定图例的位置和参数:
ht <- draw(ht, heatmap_legend_side = "right", annotation_legend_side = "right", heatmap_legend_combine = FALSE )heatmap_legend_combine = FALSE是ComplexHeatmap 2.0版本之后才提供的参数,它把主图例和annotation图例分开排列,视觉效果更接近期刊排版。如果你希望控制某个图例内部的具体样式,可以在Heatmap里写heatmap_legend_param,或者在HeatmapAnnotation里写annotation_legend_param:
HeatmapAnnotation( Group = col_group, col = list(Group = group_col), annotation_legend_param = list( title = "Group", title_position = "topcenter", ncol = 1 ) )这些参数看起来琐碎,但在投稿时很顶用。审稿人很少会因为你的图配色难看而婉拒,但会因为图例不清、群落标识不明确而反复要求修改。
4.3 导出参数:PDF字号与物理尺寸
ComplexHeatmap的导出,我始终推荐走PDF。原因很简单:热图是矢量图形,PDF能保证你在任意缩放级别下文字和分割线都清晰锐利。PNG这类位图格式一旦放大就会发虚。
导出的关键参数有两个:物理尺寸和字体。NC双栏图表的单栏宽度通常是8.9cm左右,双栏宽度在18.3cm左右。我一般按单栏8.5cm、高度视行数而定来设置:
pdf("Split_Heatmap_NC_Style.pdf", width = 8.5 / 2.54, height = 6 / 2.54, family = "sans") draw(ht) dev.off()这里有个单位换算:base C的pdf()默认单位是英寸,NC给图的尺寸是以厘米计,所以除以2.54。字体方面,我建议在图形参数里统一指定,尤其不能让默认字体和正文体差异太大:
ht <- ht + theme( row_names_gp = gpar(fontsize = 6, fontfamily = "Helvetica"), column_names_gp = gpar(fontsize = 7, fontfamily = "Helvetica"), row_title_gp = gpar(fontsize = 8, fontface = "bold"), column_title_gp = gpar(fontsize = 8, fontface = "bold") )关于热图宽高,还有一个经验规律:热图的行数超过50时,不显示的基因名会让图面多少显得"空",但这是期刊习以为常的排版;如果基因数在20个以内,通常建议保留基因名,因为这张图承载的信息密度本身就有限,加上名字才能让读者获得具体基因的指向性。
5. 复现路上最容易翻车的四个坑
写这部分之前,我特意回想了一下自己第一次复现NC分区热图时的问题清单。有些坑是代码层面,有些是认知层面,但每一条都值得写出来。
5.1 行列顺序错位:split因子没跟上矩阵
这是我遇到过的最隐蔽的错误。某个项目里我的表达矩阵在清洗阶段做过一次行过滤,然后我又用了dplyr的arrange按基因名排序。结果row_group还是按原始顺序排列的,没有和过滤后的矩阵行名重新对齐。画出来的图里,每个分区内部的基因倒是排在一起了,但左边注释条的色块却明显是"错了一个身位"的——注释条的Module颜色和行分区的内容完全对不上。
ComplexHeatmap在这里给了你一个天大的提醒:如果你的row_split因子长度和矩阵行数不一致,它会直接报错。但长度一致并不代表顺序一致,万一两边恰好数量一样,只是顺序乱了,R不会报错。所以我的习惯是画图前反复确认:
stopifnot(nrow(mat_z) == length(row_group)) stopifnot(ncol(mat_z) == length(col_group)) stopifnot(identical(colnames(mat_z), names(col_group)))如果你用的是命名向量,ComplexHeatmap会自动按名字对齐,但前提是向量有名字。所以我在前面构造row_group和col_group时强烈建议用命名向量,而不是裸因子。
5.2 分组注释条颜色失控
颜色失控有两种表现。第一种是某个分组没匹配到颜色,整个注释条变成灰色。绝大多数情况是因为col列表里的名字和分组字符串有细微出入,比如多了空格、大小写不一致。排查方法很简单:打印unique(col_group)出来看。
第二种是数值型分组被当成连续变量映射。如果你的col_group不是字符型而是数值型,比如样本分组是1、2、3,ComplexHeatmap会把它解释为连续变量,使用渐变色而不是分类色。这个问题只需要一句as.character(col_group)就能解决,但很多人会卡很久,因为他们盯着col列表怎么都看不出问题。
5.3 行名挤成一团:期刊图的处理思路
基因数量一大,行名显示出来必然糊成一团,这是新手最容易犯的错。NC上的分区热图通常会在基因数较少时显示基因名,基因数较多时干脆全部隐藏,只靠右侧的模块注释条来承担基因分组信息。
如果你确实想让读者看到关键基因的名字,有三个方案。方案一,全部不显示,靠模块注释条传达区域信息;方案二,只显示部分感兴趣基因,通过设置row_labels = ifelse(某个条件, 基因名, "")实现;方案三,把隐藏了行名的热图导出至AI软件里,在对应位置手动标注基因名。方案三在出图流程里其实很常见,因为期刊从接收到印刷,美术编辑会做大量精细排版,你不需要在R里把一切都画完。
5.4 复制到PPT/Word之后变糊
我见过太多人在R里导出300dpi的PNG,高高兴兴贴进Word,结果导出PDF后图片糊了。原因是Word会在后台压缩位图。解决办法并不是提高导出分辨率,而是直接嵌入PDF矢量图。但Word对PDF矢量的支持没有那么友好,跨平台粘贴更麻烦。
更稳妥的做法是:在R里导出高分辨率PNG,然后直接在PPT里以原始尺寸展示,导出PDF时不勾选压缩选项。如果投稿系统允许,直接上传PDF原始文件当然最佳。我还会顺手用magick把PDF转成300dpi的PNG备用:
library(magick) img <- image_read_pdf("Split_Heatmap_NC_Style.pdf", density = 300) image_write(img, "Split_Heatmap_NC_Style.png", format = "png", density = 300)这一步也能一举解决"PDF里字显得很小"的问题——因为你在R里设置的fontsize,要想在投稿系统里呈现合适的大小,最终还是要经过density换算。
6. 最后再分享一个我复现分区热图时最受用的习惯
我每次拿到一篇文章的图,第一步做的不是敲代码,而是用PPT手动画一个结构草图:主热图在中间,左边从上到下标出行分区,顶部标出列分区,然后给每个分区命名。这个草图不需要精确到颜色,只需要把每层信息落在纸面上。
做完这一步,我再回过头看数据,心里就非常清楚:哪张表对应主矩阵、哪列metadata对应行注释、哪列metadata对应列注释。整套流程下来,真正写代码的时间其实只有二十分钟,剩下的大把时间都花在看懂数据、核对顺序、微调颜色上。分区热图这种图,难点从来不是"画出来",而是"画得和文章想表达的逻辑一致"。你把分组语义理清了,代码反而是水到渠成的事。