前些天整理单细胞项目结果,又被审稿人问了一句“你的热图除了颜色深浅还能看出什么”。这句话戳到我了。单细胞转录组分析里,热图几乎是标配,但绝大多数人画出来的热图,就是一个“表达量颜色块”,既看不出细胞亚群的差异结构,也讲不出基因模块的变化趋势,更别说把差异基因、富集通路、拟时轨迹这些东西和表达模式串在一起。
所以这次我想聊的不是“怎么用pheatmap画一张热图”,而是从单细胞基因表达可视化的核心逻辑出发,把热图当作一张可以承载多维信息的主图来改造。本文整理的是我在实际项目里反复调过的方案和踩过的坑,主题就叫“单细胞基因可视化之热图的根本改造2”——如果你也受够了那种“千图一面”的普通热图,这份笔记应该能帮上忙。
1. 热图改造的核心思路:为什么普通热图不够用
1.1 单细胞数据的热图,本质上是在展示“分组结构”
很多教程会把热图简单理解成“展示基因表达高低”,其实这个理解太浅了。单细胞转录组项目里的热图,不管画哪种,背后都藏着一个核心需求:用基因表达模式来定义或者验证细胞的identity。换句话说,热图不是给人看颜色深浅的,而是让人一眼看出“这群细胞和那群细胞的分子特征差异在哪”。
这就引出了普通热图的两个根本问题。第一,行和列的排列顺序往往没有生物学意义,或者只有字母序(比如基因名从A到Z),这样的热图看完了只能得到“基因A高表达、基因B低表达”这种零散信息。第二,热图旁边缺少上下文信息——细胞是哪来的、属于哪个亚群、关键Marker基因的表达丰度分布、通路活性高低、差异是否显著,这些信息如果全部挤在主图里,就会变成一团彩色噪声。
所以“根本改造”的第一件事,就是改变热图的信息组织方式:行(基因/特征)按表达模式或功能模块聚类排序,列(细胞/样本)按细胞亚群或实验分组排序,同时把各种注释信息放到热图外侧。这样做的目的不是说主图不重要了,而是让主图和注释一起构成“可读”的证据链。
1.2 三个维度:从“颜色矩阵”升级到“结构视图”
这里我要重点说下搜索热词里反复出现的“三个维度的热图”。单细胞热图的数据矩阵天然有三个维度——细胞、基因、表达值。普通热图其实只用了二维:用行、列的位置分别对应基因和细胞,用颜色深浅对应表达值。这没错,但问题是它把所有信息强行摊平到一张二维色块上,丢失了“上下文”。
我理解的“三维热图”,不是真的去画3D立体柱状图(那种图看着炫酷,实际很难读出精确的数值关系),而是给热图叠加三层结构信息:
- 第一层:主热图矩阵,展示基因×细胞的表达模式,通常经过Z-score归一化;
- 第二层:细胞侧的结构注释,包括亚群来源、样本批次、细胞周期评分、拟时序位置;
- 第三层:基因侧的生物意义注释,比如是否属于某个通路、是否是Marker基因、差异显著性标记。
这三层信息叠在一起,热图就从“表达量色块”变成了“细胞状态结构视图”——谁和谁像、哪类基因在哪群细胞里特异地活跃,一目了然。这也是我在实际项目中尝试过效果最明显的改造方向。
1.3 改造前后的信息密度对比
我以前做单细胞项目时,热图基本是这样画的:用Seurat的DoHeatmap函数出一个初版,行是Top差异基因,列是细胞(按cluster排序),颜色是红蓝渐变。然后把这个图丢到PPT里,旁边再放一个小提琴图、一个气泡图。
问题在于:单个热图承载不了逻辑关系,审稿人或者老板看到热图之后,往往还得再问一句“这些基因在功能上有什么联系”。所以到了“改造2”阶段,我给自己定了三条硬性标准:
- 热图必须能单独讲清楚一个生物学结论(比如“这簇基因负责炎症响应,特异地在巨噬细胞亚群中上调”);
- 热图必须能叠加统计信息(差异基因的显著性标记、通路富集的分组背板);
- 热图必须能在不参考其他图的情况下,让读者看懂细胞亚群和基因模块的对应关系。
为了达到这三条,我用ComplexHeatmap重写了热图绘制流程,并且把所有公共注释信息(亚群颜色、基因分组、给富集通路、差异显著性)都变成了“可配置的组件”。这样同一个基础热图,稍改参数就能复用到不同项目中。
2. 热图根本改造的基础设施:聚类、归一化与配色决策
2.1 聚类不是“点一下就行”的按钮
画热图的时候行和列都会被聚类,但默认聚类的坑非常深。很多教程就直接pheatmap(mat),结果出来之后行、列顺序由欧氏距离和ward.D2决定。这在单细胞数据里往往不是最优选择。
举个例子,单细胞表达矩阵稀疏性极高,存在大量零值。如果直接拿原始的count矩阵或者log1p后的矩阵算距离,零值过多会导致样本之间“因为都是零而显得相似”,这种相似性掩盖了真正的生物学差异。所以我的习惯是:先对基因做Z-score归一化(按行),再用归一化后的矩阵做聚类。
Z-score的意义在于把不同基因的绝对表达量拉到一个可比尺度上。基因A的平均表达量是5、基因B的平均表达量是50,如果没有归一化,它们出现在同一张热图里时,基因B会把基因A的差异“压扁”。Z-score之后,每个基因的表达分布均值是0、标准差是1,热图上的颜色只反映“相对上下调”,不反映绝对表达量,这才能把调控模式凸显出来。
具体在R里怎么做,我放一段常用代码:
# 假设 mat 是基因×细胞矩阵(行=基因,列=细胞) mat.raw <- as.matrix(GetAssayData(sce, layer = "data")) # 仅保留感兴趣基因,例如Top差异基因或Marker基因 mat.sub <- mat.raw[genes_interest, ] # 按行(基因)做Z-score归一化 mat.z <- t(scale(t(mat.sub)))注意:
scale是按列操作的,所以要先把矩阵转置,Z-score后再转置回来。这个顺序搞反了,结果会变成“按细胞归一化”,那整个热图的模式就会彻底变味。
聚类距离的选择上,我倾向于用euclidean配ward.D2,或者pearson相关距离。对于单细胞数据,Pearson相关距离的好处是它不关心绝对表达量,只关心表达“模式”是否一致。也就是说,两个基因一个是高表达一个是低表达,但它们在所有细胞里的变化趋势是同涨同跌的,Pearson距离会认为它们很接近。这在看共调控基因模块时非常有用。
2.2 颜色映射的坑:红色代表高表达?蓝色代表低表达?没那么简单
热图的颜色配置,很多新手以为只是个审美问题。实际上颜色映射决定了读者对“差异”的感知强度,选不好会误导解读。
最常见的是红蓝渐变(RdBu)或红绿渐变。在单细胞文献里,高表达通常用红色、低表达用蓝色,这种习惯跟直觉一致。问题出在中值颜色(白色或浅灰色)上。如果Z-score之后的数据大概在-2到2之间分布,那么中值颜色对应的就是0附近(即表达量没有显著偏离均值)。但如果你用默认的渐变色阶,最大值和最小值被拉伸到色阶两端,那么大部分普通基因的Z-score值集中在-0.5到0.5之间,显示出来基本是一个颜色,热图的层次感就没了。
我的做法是手动控制颜色断点。用circlize::colorRamp2,指定几个关键值对应的颜色,而不是依赖连续渐变自动映射。比如:
library(circlize) col_fun <- colorRamp2( c(-2, -1, 0, 1, 2), c("#2166ac", "#92c5de", "#f7f7f7", "#f4a582", "#b2182b") )这样颜色集中在-2到2之间,超出这个区间的极端值会被锁定为最深色,不会让色阶“溢出”。如果数据里有某个基因在某些细胞里Z-score达到了5,不截断的话整个热图都会被这一个极值带偏。
另外,千万别小看色盲友好配色。生物学论文审稿里,红绿配色是重灾区——红绿色盲是最常见的色盲类型。我自己在改造代码时统一换成了蓝-白-红系或者viridis系,实测下来既好看又安全。
2.3 行列注释的布局:信息放对位置,图才讲得清楚
热图改造里最立竿见影的一步,是给热图加上注释条(annotation bar)。注释条的本质是在热图旁边补充“主图表达矩阵里看不出来的分组信息”。
行注释(gene side)可以放的信息包括:基因所属通路、基因类型(如离子通道、转录因子、细胞因子)、差异分析中的显著方向和log2FC大小。列注释(cell side)可以放的信息包括:细胞亚群(cluster)、样本批次、供体ID、细胞周期评分、拟时序分支。
在ComplexHeatmap里,列注释放在顶部或底部,行注释放在左侧或右侧。我的建议是:细胞身份类信息(如cluster)放顶部,因为顶部最容易看;基因功能类信息放右侧,因为它是对基因的补充说明,不抢占主视觉。
library(ComplexHeatmap) library(ggplot2) col_ha <- HeatmapAnnotation( cluster = sce$seurat_clusters, sample = sce$sample_id, col = list( cluster = cluster_colors, sample = sample_colors ), annotation_height = unit(c(4, 4), "mm"), show_annotation_name = TRUE ) row_ha <- rowAnnotation( pathway = gene_pathway_factor, significant = gene_sig, col = list( pathway = pathway_colors, significant = c("up" = "#d73027", "down" = "#313695", "ns" = "#969696") ), annotation_width = unit(c(6, 4), "mm") )这样搭好框架后,行和列的方向都可以自由切换,注释和主图对齐问题由ComplexHeatmap内部处理,很少出现“注释条和热图错位”的情况。这是pheatmap做不到的——pheatmap的注释列在边上,但排版可控性差很多。
3. 实操:基于ComplexHeatmap实现三个维度的热图改造
3.1 从pheatmap迁移到ComplexHeatmap的流程
很多人的热图入门工具是pheatmap。它简单、够用,但一旦要做“三个维度的热图”,pheatmap就力不从心了。pheatmap主要痛点:注释只能放“列”或“行”中的一个层面,无法同时精确控制多个注释图例;无法拆分行/列的多层级分组;无法在热图内部叠加显著性标记或分组边框。
迁移到ComplexHeatmap有一段小陡坡,但它值得学。核心逻辑是“热图就是一个对象,任何注释、拆分、标记都是往这个对象上叠图层”。我用一个案例来说明完整迁移过程。
假设我们有一个单细胞数据集,经过Seurat标准流程处理,得到了细胞亚群、Marker基因列表、差异基因列表和通路富集结果。现在要画一张Top差异基因热图,同时展示:
- 每个细胞来自哪个亚群(顶部注释);
- 每个细胞来自哪个样本(顶部注释第二层);
- 每个基因是否属于目标通路(右侧注释);
- 行按Cluster差异基因分组,并用色块标出基因归属的差异群组;
- 表达矩阵Z-score归一化。
代码骨架如下:
library(Seurat) library(ComplexHeatmap) library(dplyr) # 1. 提取数据矩阵,仅保留Marker基因 Idents(sce) <- "seurat_clusters" markers <- FindAllMarkers(sce, only.pos = TRUE, min.pct = 0.25, logfc.threshold = 0.5) top_markers <- markers %>% group_by(cluster) %>% slice_max(n = 10, order_by = avg_log2FC) mat <- GetAssayData(sce, layer = "data")[unique(top_markers$gene), ] mat.z <- t(scale(t(mat))) # 2. 细胞注释 col_ha <- HeatmapAnnotation( cluster = as.character(sce$seurat_clusters), sample = as.character(sce$sample_id), col = list( cluster = setNames(ggsci::pal_lancet()(9), levels(sce$seurat_clusters)), sample = setNames(ggsci::pal_npg()(6), unique(sce$sample_id)) ) ) # 3. 行分组:按基因属于哪个cluster的marker来分组 gene_cluster <- top_markers$cluster names(gene_cluster) <- top_markers$gene gene_cluster <- gene_cluster[rownames(mat.z)] # 4. 画热图 ht <- Heatmap( mat.z, name = "Z-score", col = col_fun, top_annotation = col_ha, cluster_columns = TRUE, cluster_rows = FALSE, row_split = gene_cluster, row_title_rot = 0, row_gap = unit(2, "mm"), cluster_row_slices = TRUE, show_row_names = TRUE, row_names_gp = gpar(fontsize = 8), column_title = "Single-cell Heatmap" ) draw(ht, merge_legend = TRUE)关键点在于row_split = gene_cluster。这会按照基因所属的cluster把热图从行方向切成若干块,每个块顶部会有一个“cluster X markers”的标题。同时cluster_rows = FALSE,意思是每个块内部的基因顺序保持差异基因列表的排序,不重新聚类。这样做的原因是:Marker基因列表通常按log2FC降序排列,块内的顺序本身就是“最特异基因在前”,保留这个顺序比重新聚类更能体现基因的rank意义。
3.2 聚类热图加趋势图的组合画卷法
单独一张热图再好看,能承载的信息还是有限。我在“改造2”里用得最多的一种组合方案是:中间画聚类热图,左侧画基因模块的表达趋势图,右侧画富集条目。这套组合直接呼应了搜索热词里的“聚类热图+趋势图+富集条目”。
思路是这样的:先用层次聚类或者K-means把基因分成若干模块(module)。然后:
- 热图部分展示每个模块基因的表达模式;
- 趋势图部分,按细胞亚群计算每个模块的平均表达量,画出折线图或者箱线图,展示这个模块在不同亚群中的动态变化;
- 富集条目部分,对每个模块的基因做GO/KEGG富集,把最显著的通路名放在热图右侧,并以颜色或者字号标注p值。
实现上用ComplexHeatmap的rowAnnotation加自定义panel函数,或者用ComplexHeatmap::anno_*系列。最简单的方式,是用anno_line画趋势线:
# 先计算每个cluster的平均模块得分 module_score <- function(mat, module_genes, sce) { sub <- mat[intersect(module_genes, rownames(mat)), ] if (is.null(dim(sub))) return(NULL) # 每个cluster列取平均值 cluster_mean <- t(apply(sub, 1, function(x) { tapply(x, sce$seurat_clusters, mean) })) # 模块整体趋势,再求基因平均 apply(cluster_mean, 2, mean) } trend_list <- lapply(module_list, function(gn) { module_score(mat, gn, sce) }) # 画趋势图注释 trend_ha <- rowAnnotation( trend = anno_lines( trend_list, add_points = TRUE, ylim = c(0, max(unlist(trend_list))), gp = gpar(col = module_colors), pt_gp = gpar(col = module_colors, cex = 0.5), axis = TRUE ) )注意:anno_lines的参数结构对象不太直观,第一次用容易把trend_list的格式传错。这里要求的是一个列表,列表里每个元素对应一行基因的数值向量;如果每个模块有多行基因,需要先按行计算模块平均表达再传入。另外,模块数量多、每个模块基因数量差异大时,趋势图Y轴上限最好手动定,避免某些模块的极端高表达把其他模块压扁。
富集条目部分,通常不需要画在热图上,而是在热图右边用文字注释粘贴最显著通路的名称。ComplexHeatmap的anno_text或者rowAnnotation里的text参数都能实现。我的习惯是每个模块最多显示2~3条通路名,否则右侧注释区会被文字堆满。
3.3 差异基因环形热图的实战细节
搜索热词里的“差异基因环形热图”也是一个值得讲的结构。环形热图的本质是把热图从左到右的矩形布局改成圆形布局。这么做不是为了炫技——当基因数量很大,行数超过200甚至500时,矩形热图的高宽比变得非常不协调,横向拉得很宽、纵向挤得很密,行名根本显示不全。环形热图能把更多基因塞进同样面积的画布里,同时中部空白区域还可以叠加更多信息。
在R里画环形热图,用的还是ComplexHeatmap,只不过把Heatmap放进draw时设置circular = TRUE。以下是关键参数:
ht_circ <- Heatmap( mat.z, name = "Z-score", col = col_fun, circular = TRUE, cluster_rows = TRUE, cluster_columns = FALSE, show_row_dend = TRUE, show_column_names = FALSE, use_annotation_legend = FALSE, row_names_gp = gpar(fontsize = 6), top_annotation = HeatmapAnnotation( cluster = sce$seurat_clusters, col = list(cluster = cluster_colors) ) ) draw(ht_circ, merge_legend = TRUE)环形热图的坑在于:行方向的标签旋转和位置控制特别不直观,row_names_gp字号小了看不清,大了互相重叠;而且行名默认是沿着圆的径向排布,如果基因名过长,会在圆的外侧“飞出去”。我的处理方法是:只显示关注的一小部分基因名(比如每个Top模块的Top3基因),其余不显示。
另外一个实用技巧:环形热图中间的空白区域非常适合放一个“表达总览”的小图,比如各细胞亚群的基因表达雷达图或平均表达量柱状图。ComplexHeatmap里可以通过draw之后用grid在圆心位置添加内容实现,但这个操作比较进阶,普通应用能控制好外圈布局就够了。
4. 常见问题与排查技巧实录
4.1 行列顺序不是自己想要的,怎么办
这是热图改造里最常被问的问题。有人画出来的热图,列的顺序不是按亚群编号排列的,而是按聚类树结构排列的,导致同一群细胞没有连在一起,图形很乱;还有人希望行的顺序严格按照自己提供的基因列表顺序,但ComplexHeatmap默认会重新聚类。
解决方案有两个。第一,cluster_columns = FALSE,然后手动设置column_order为细胞顺序的向量;第二,cluster_rows = FALSE,让行顺序严格保留输入矩阵的行名顺序。比如:
# 手动指定列顺序:先按cluster排序,再按组内表达量均值排序 cell_order <- order(sce$seurat_clusters, colMeans(mat.z)) Heatmap( mat.z, cluster_columns = FALSE, column_order = cell_order )提示:手动指定列顺序之前,一定要确认
cell_order的长度和矩阵列数一致,而且顺序向量里的每个元素都能在矩阵列名中找到。不然draw的时候会直接报错。
4.2 Z-score之后出现NaN或者Inf值
单细胞表达矩阵里,如果一个基因在所有细胞里的表达都是0,那么scale之后标准差为0,Z-score的结果就会出现NaN。这些NaN在热图上会被当作缺失值显示成灰色,非常难看。我用两个办法处理:
一是过滤,直接剔除全零或近乎全零的基因;二是填充,把NaN值替换为0(意思是“没有偏离均值”)。代码里我一般这样处理:
# 过滤全零行 keep <- apply(mat.raw, 1, function(x) var(x) > 0) mat.raw <- mat.raw[keep, ] # 少量NaN兜底 mat.z[is.na(mat.z)] <- 0这个兜底有生物学上的合理性:某基因在所有细胞里恒定不变,它在热图里应该显示为白色(Z-score=0),而不是缺失的灰色。灰块会让人误以为检测失败。
4.3 注释条的颜色和热图图例重复造成混乱
ComplexHeatmap的图例默认会自动合并。当你有多个注释,比如cluster有9个水平、sample有6个水平,基因分组有4类,再加上Z-score的连续色阶,图例会一大堆。处理办法:
merge_legend = TRUE,把注释图例和主图例合并排列;- 用
heatmap_legend_param和annotation_legend_param分别控制各图例位置; - 如果注释图例太多,可以考虑只保留cluster注释的图例,sample和pathway注释的图例用
show_legend = FALSE隐藏,然后在图题或图注里文字说明。
4.4 热图保存时字体丢失或显示模糊
R里直接ggsave或pdf保存热图,有时会出现中文字体变方块、英文字体大小不一致的问题。ComplexHeatmap的字体由gpar(fontfamily = ...)控制。如果要出版级别的图,我建议保存为PDF或者300dpi以上的TIFF:
pdf("heatmap_final.pdf", width = 8, height = 10) draw(ht, merge_legend = TRUE) dev.off()不要直接用png保存低分辨率再插入Word或PPT里拉大,那样文字边缘一定发虚。另外,如果期刊要求矢量图,PDF是首选;如果要求位图,至少width=3000, height=4000, res=300。
4.5 大规模矩阵画图卡顿
上千行乘上数万列的热图直接绘制,内存占用会非常夸张。三个可以优化的方向:
- 抽样细胞:每组亚群随机抽50~100个细胞画热图,表型趋势不会变,速度提升10倍以上;
- 只画Top基因:筛选每个亚群前10~20个Marker基因,而不是把所有差异基因都画进去;
- 使用
Heatmap的raster = TRUE参数,把热图栅格化为位图保存,大幅降低渲染压力。
Heatmap( mat.z, raster = TRUE, raster_device = "png", raster_quality = 2 )注意:
raster = TRUE会把热图主体部分保存为位图,导出矢量PDF时能大幅减小文件体积,但栅格化之后如果缩放大到某比例,图表面可能看出像素点。期刊图建议关闭raster,先用抽样策略减规模。
5. 工具链选型与改造路线图
5.1 为什么我最终选择ComplexHeatmap
市面上画热图的工具不少:R里有pheatmap、ComplexHeatmap、ggplot2的geom_tile;Python里有seaborn的clustermap、plotly的热力图、matplotlib的imshow。从我自己的项目实践来看,如果目标是做“三个维度的热图”这类结构化信息密度极高的可视化,ComplexHeatmap是当前最合适的选择。
原因有三个:
- 它对热图的拆分支持最好。
row_split、column_split能把热图切成任意复杂度,实现分组而不重排数据; - 它把注释当成“独立组件”。注释和主图是平行关系,可以非常灵活地变换位置、控制颜色、叠加图例;
- 它支持自定义图形嵌入。只要你会写R的grid绘图逻辑,几乎任何内容都能嵌入热图的注释区域。
相比之下,seaborn的clustermap美观但定制空间有限,复杂注释全靠手工拼图。ggplot2的geom_tile灵活但性能差,几万列的数据它根本扛不住。如果你的项目规模很大,ComplexHeatmap确实能省心很多。
5.2 “改造2”的完整落地路线图
根据我在两个真实单细胞项目里的实践,总结一条可复制的落地路线:
- 第一步:整理数据。从Seurat或Scanpy对象中导出表达矩阵、细胞元数据、Marker基因列表。这个阶段的关键是保持基因名和细胞ID的格式统一,避免后面join时出现匹配不上。
- 第二步:归一化。按行做Z-score,过滤低变异基因,确认无NaN。
- 第三步:定注释面板。列注释至少包含cluster和sample,行注释至少包含基因所属模块。配色方案提前定好,全局用同一套颜色。
- 第四步:画基础热图。先不追求复杂注释,把主热图的行列顺序跑通。
- 第五步:迭代加注释。加趋势图、富集条目、显著性标记,每次只加一个元素,确认图片没有信息重叠再继续。
- 第六步:导出与检查。用PDF导出后放大到200%检查字体和色块边界,重点看行名有没有重叠、色阶对比是否明显。
最后再分享一个小经验:热图改造不要一口气追求把所有信息都画进去。信息过载比信息不足更可怕,一张图上塞了五个维度的注释,人眼反而抓不住重点。最好的做法是做一个“主图+配套注释”的系列图——主图承载表达模式,扩展图承载富集结论,两者配合讲完一个完整的生物学故事。这也正是“根本改造2”这个标题里“根本”二字的含义:不是给热图换个皮肤,而是把表达矩阵从数据展示工具,变成生物学结论的推导工具。