多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

单细胞热图根本改造:从二维矩阵到多维信息可视化实战

单细胞热图根本改造:从二维矩阵到多维信息可视化实战 单细胞基因可视化之热图的根本改造2这个标题如果你刷到过说明你多半也在单细胞数据分析这条路上被热图折磨过。上一篇我聊了聊热图改造的初步思路——从配色、排序、注释栏这些面子功夫入手给传统热图做了第一轮升级。但说实话那轮改造做完以后我心里一直不太踏实因为单细胞数据本身是多维的而热图本质上是二维矩阵这中间的鸿沟不是换个配色、调调聚类就能填平的。所以这篇我打算动真格把热图从一个矩形色块矩阵改造成真正能承载单细胞复杂信息的复合可视化载体。这篇文章不是理论堆砌是我在自己的单细胞项目里实际跑过的改造路径。适合已经会画基础热图、但对展示效果和信息密度不满意的同学也适合正在琢磨怎么把热图放进数据可视化大屏、给非生信背景的人讲清楚结果的朋友。我会把改造的思路、每个选择的理由、具体的代码实现以及踩过的坑都摊开讲。1. 先拆解传统热图在单细胞场景下的三个致命短板1.1 信息扁平化一个二维矩阵装不下单细胞的真实故事单细胞测序数据最核心的特征就是细胞异质性——同一个组织里有几十种细胞类型每种类型有自己的基因表达特征。传统热图的做法是把细胞聚成类然后展示每个类里的平均表达量或者直接展示所有细胞。但问题在于当你把几万个细胞压成一个矩阵每一列是一个细胞或一个类群每一行是一个基因的时候中间的过程信息全丢了。举个例子我在一个肿瘤单细胞项目里要展示CD8T细胞的耗竭状态。传统热图可以展示TCF7、GZMB、PDCD1这几个基因的表达量但热图只会告诉你在某个亚群里这些基因高表达。它告诉不了你的是耗竭T细胞是从哪个状态过渡过来的PDCD1是从哪一步开始上调的TOX在这个动态过程里扮演了什么角色。这些都是细胞层面的时间序列信息而二维热图是静态的、扁平的天然表达不了。要解决这个问题不能只在热图本身上下功夫得重新思考热图在整个可视化流程中的定位。我的做法是把热图从最终展示品降级为核心组件让它和其他图表类型组合共同讲述一个完整的故事。这个思路贯穿全文也是我这轮根本改造的出发点。1.2 基因顺序玄学看似中立的排列其实在误导读者默认的热图基因顺序是基于聚类结果的聚类算法把表达模式相似的基因放在一起。这个逻辑本身没问题但在单细胞场景里有个隐患——聚类反映的是相似性不是生物学逻辑。两个基因聚在一起可能仅仅是因为表达量都低而它们在通路里的上下游关系完全可能是相反的。我在做细胞周期相关基因展示时就吃过这个亏。用默认聚类排序CDK1和CCNB2挨在一起看起来好像它们应该是一条线上的但实际上前者是激酶后者是细胞周期蛋白它们在调控网络里并不是紧邻的。如果读者正好是非生信背景的合作者很容易被这个聚类相邻的关系误导。所以我的改造原则之一就是基因顺序要么按生物学功能分块比如按通路、按先导基因-效应基因的关系要么按时间/轨迹顺序排列比如拟时序分析得到的基因动态顺序。默认聚类的好看的随机性在这个场景下反而是有害的。1.3 无法承载单细胞特异性平均表达掩盖了分布形态传统热图最容易被忽略的一个缺陷就是它默认均值有意义。但在单细胞数据里基因表达经常是双峰甚至多峰分布的——一部分细胞完全不表达一部分细胞高表达平均值恰好卡在中间看起来好像中等表达。这个分布形态信息是传统热图完全展示不出来的。这里我需要多说一句因为很多人包括曾经的我会在这一步困惑单细胞数据稀疏性这么高做热图前要不要进行插补imputation我的观点是如果目的是探索性可视化不要轻易插补。插补会抹掉真实的0表达信息让双峰分布看起来像正态分布这恰恰是热图改造要避免的。要展示分布形态我们可以用点图dot plot来辅助或者在热图基础上叠加统计信息——具体怎么做后面会细说。2. 改造的第一层给热图加上第三维度的表达通道2.1 坐标变换一个反直觉但有效的思路聊到三维热图不要误会我要画那种立体柱子、3D旋转的花架子热图。那种图在可视化大屏上确实唬人但信息损失严重而且旋转视角反而会扭曲数值对比。我说的三维是在标准二维热图的坐标体系之外增加第三个信息维度。这听起来玄实际上做法很朴素。最经典的方案就是把常规的基因 x 细胞矩阵拆解成三个通道X轴细胞分群或样本分组Y轴基因或功能通路颜色深浅表达量的平均值这三个维度都保留之后再额外增加一个表达细胞比例的维度。你可以用点的大小来表示表达比例同时用颜色表示表达强度——这就是seurat的DotPlot思路但我的改造要走得更远。核心思路是热图只能利用颜色这一个视觉通道而人眼能同时处理的通道至少有5个颜色、大小、形状、位置、纹理把多个通道利用起来才能装下更多信息。2.2 覆盖率的引入一个解决双峰分布的实操方案针对前面说的均值掩盖分布问题我在项目里用了一个组合方案热图展示关键基因在每种细胞类型中的平均表达量log2标准化后的均值同时在每个热图格子上叠加一个小圆点圆点大小代表表达该基因的细胞比例即覆盖率pct.exp。这样一个格子同时展示两重信息颜色深浅告诉你平均表达多高圆点大小告诉你有多少细胞在表达。这个方案的优势在于公平——如果某个基因在10%的细胞里表达量爆表平均下来显得很高但圆点很小读者一眼就能看出这只是少数细胞的事件。反过来如果基因在80%的细胞里稳定表达即使平均表达量不高大圆点也清楚地告诉你这是个广泛表达的保守基因。实现层面很简单Seurat里直接有这个统计量library(Seurat) # 假设你的Seurat对象是objgroup.by是细胞分群列名 data - DotPlot(obj, features markers, group.by celltype)$data # data里包含avg.exp, pct.exp两列 head(data)有了这两列数据就可以用ggplot或ComplexHeatmap自定义绘图。我个人是用ComplexHeatmap因为它的图层叠加能力更强热图主体的信息密度更高。用ggplot也能做但热图边缘的注释栏整合起来比较麻烦。2.3 热图颜色映射的阈值设置与视觉公平这里要专门说一下颜色映射的截断z-score的上下限。做单细胞热图最常见的错误是直接喂原始表达值给热图函数导致几个高表达基因把整个配色方案拉到饱和区低表达基因全变成深蓝色完全分不开。我的经验是先对表达矩阵做Z-score标准化按行也就是按基因标准化然后强制设置一个颜色截断区间比如[-2, 2]或[-1.5, 1.5]。超过截断值的数值统一映射到最深的颜色。这样做的逻辑是在展示基因表达模式时我们关心的是哪些基因在哪些细胞里相对高表达Z-score比原始表达值更能体现这个相对关系。截断是为了避免极端值主导视觉对比。具体代码ComplexHeatmap里这样处理library(ComplexHeatmap) library(circlize) # mat是基因x细胞的表达矩阵已经做log1p或TPM标准化 # 按行做Z-score mat_z - t(scale(t(mat))) # 截断 mat_z[mat_z 2] - 2 mat_z[mat_z -2] - -2 col_fun - colorRamp2(c(-2, 0, 2), c(#2166AC, #F7F7F7, #B2182B)) Heatmap(mat_z, name Z-score, col col_fun, show_row_names TRUE, show_column_names FALSE)3. 改造的第二层从矩形矩阵到环形热图3.1 为什么在单细胞场景会用上环形布局环形热图在生信领域一直被用得不多但它在两种特殊场景下有奇效。第一种是展示基因组坐标相关的基因分布——比如一个染色体区域里的基因在不同细胞群里的表达情况矩形热图会拉得很长环形热图能充分利用画布空间。第二种是展示周期性或循环性的生物学过程例如细胞周期。我在一个细胞周期项目中用了环形热图。当时要展示G1/S/G2/M四个时期相关的几十个基因在不同细胞周期阶段里的表达变化以及基因沿染色体位置的分布。如果画成矩形热图染色体的长度差异会导致某些染色体区域被压缩得完全看不清用环形布局每根染色体是一个环基因按坐标排列在环上颜色表示表达量直观且不浪费空间。3.2 环形热图的实操要点从矩阵到坐标的转换环形热图的核心是坐标系统替换。在R里我推荐用circlize包配合ComplexHeatmap来画。思路分为三步第一把表达矩阵关联到基因的染色体坐标。这需要一个基因注释文件比如TxDb数据库或者GTF文件转换出来的gff。整理成下面这种格式gene_info - data.frame( gene c(TP53, EGFR, MYC), chr c(chr17, chr7, chr8), start c(7572928, 55086719, 128748314), end c(7590868, 55211628, 128753680) )第二按坐标把表达值映射到环形轨道的特定位置。这一步用circos.genomicTrack来画把矩形热图里的每个格子变成环形轨道上的一个柱子或色块library(circlize) circos.initializeWithIdeogram(species hg38) # 或者用自定义的chr长度 # 准备基因表达数据包含chr, start, end, expr列 circos.genomicTrackPlotRegion( data expr_data, track.height 0.2, panel.fun function(region, value, ...) { circos.genomicRect(region, value, col col_fun(value[[1]]), border NA) } )第三在环形内圈或外圈叠加细胞类型注释、差异基因标记等图层。这一步是表达信息的关键。我会在外圈用不同颜色条带标记不同的基因功能分组增殖、凋亡、免疫检查点等让读者对哪些区域富集了哪些功能基因一目了然。注意环形热图好看但花时间如果只是常规展示几十个基因的表达模式不建议用。它真正发挥价值的场景是基因数量多且带坐标信息时。这个判断我觉得很重要——不要为了炫技而改造改造的前提是信息确实这样表达更好。3.3 差异基因环形热图的展示设计我在差异基因环形热图里的做法是把比较组别间的显著差异基因按染色体位置分组然后在环形轨道上用颜色和高度一起表示log2FoldChange和-log10(p-value)。简单说柱子越高代表差异越显著颜色越红代表上调倍数越大越蓝代表下调倍数越大。这个方案比矩形热图的优势在于一个差异基因列表往往几百上千个基因矩形热图按行排列根本看不出规律但把它们映射到染色体位置后立刻能发现某些基因组区域显著富集差异基因。这在做肿瘤基因组变化分析时是很重要的生物学洞察。比如我在一个肺癌项目里发现9号染色体上有一段区域的基因几乎一致上调后来验证是一个局部拷贝数扩增事件驱动了这些基因的高表达。这个发现如果只看传统热图的聚类几乎不可能看出来。4. 改造的第三层热图 趋势图 富集条目的复合视图4.1 复合视图的组合逻辑让不同信息各就各位现在聊我在这轮改造里最得意的一个成果——把热图和趋势图、富集条目整合成一张复合图。这个想法的直接来源是我拿了热图去给课题组做组会汇报时的反馈。老板盯着热图问这几个基因为什么在T细胞亚群里逐渐降低它们富集在什么通路里我当时给不出直观的答案只能在热图和富集分析结果之间来回切换PPT页面效果很糟糕。复合视图的布局思路是中间是基因 x 细胞类型的核心热图右侧顺势接上每个基因对应的表达趋势图小型的折线图显示这个基因在不同细胞类型中的表达量变化趋势底部再接上一个富集分析结果的条目图——上下左右对应关系清晰一张图把一个基因表达特征的所有信息打包。我用了ComplexHeatmap的灵活布局特性来实现核心代码逻辑如下library(ComplexHeatmap) # 核心热图基因 x 细胞类型的Z-score ht_main - Heatmap(mat_z, name Z-score, column_split celltype_order, cluster_columns FALSE, cluster_rows TRUE) # 右侧趋势图用anno_link把基因连接到小型折线图 library(ggplot2) # 假设trend_df每个基因有在不同细胞类型的表达均值 line_plots - lapply(rownames(mat_z), function(g) { df - data.frame(celltype celltype_order, expr trend_df[g, celltype_order]) ggplot(df, aes(x celltype, y expr, group 1)) geom_line() geom_point(size 0.5) theme_void() ylim(0, 5) }) ht_right - rowAnnotation( trend anno_zoom(align_to split_rows, which row, panel_fun function(index) { grid.text(...) }), width unit(2, cm) )这里有个技术细节值得说行注释里的迷你趋势图必须对齐热图的每一行所以不能简单地嵌图而要用anno_zoom或者预先算好坐标的grid.lines手工对齐。我最终用的是复杂展示方式——根据热图行聚类顺序预先计算每一条线的坐标点用grid.lines直接绘制。这样可以精确控制每一个基因的位置关系。4.2 富集条目怎么压到同一张图里底部富集条目的实现相对独立核心是把富集分析的结果比如GO BP或者KEGG通路转成一个水平条形图条形的长度表示富集到的基因数量颜色表示p值。然后利用ComplexHeatmap封装好的HeatmapAnnotation或者自己用grid布一个viewport把它嵌到主热图下方。实现的时候有个小坑富集结果的热图行数和主热图的列数没有必然关系缩放比例要对齐。我的做法是设定好两个热图的相对宽度让主热图占70%富集条形图占30%然后用draw()函数把它们组合起来。整个复合视图的最终效果就是核心热图表达模式 右侧折线图表达趋势 底部富集条目功能解读三合为一。我在项目里把这个图用于细胞亚群鉴定结果的展示——亚群标记基因的表达特征、亚群间表达差异的趋势、以及这些基因富集的生物学通路一张图全讲清楚组会汇报效率提高了一个档次。4.3 布局密度与可读性的平衡经验复合视图最大的风险是信息过载。我做过一个版本把所有基因名、细胞类型名、富集条目全部标出来结果一张A4纸的图密密麻麻谁也看不清。后来我把坐标轴标签改成按需显示——核心基因名要显眼显示其他基因如果图空间不足可以缩小字号或者用编号代替在图的全部信息中另加一个基因名列表。这里需要提一下经验数值一屏大小的展示区域里热图主体推荐放在300-400个基因以内超过这个量标签无法看清建议做基因筛选。筛选逻辑可以用分组内差异最显著或方差最大的前N个基因而不是单纯取高表达基因。我个人更推荐按方差筛选因为方差大意味着基因在细胞类型间有明显差异才能看出表达模式的区分。5. 可视化大屏场景下的热图改造实践5.1 从静态报告到交互式大屏单细胞热图的数据压缩策略我说这个系列是根本改造是因为现在的应用场景变了——不只是发论文用静态图还要把单细胞分析结果放在内部数据看板或者可视化大屏上做团队协作展示。在这种场景里传统热图有一个致命问题几万个细胞直接灌进浏览器会卡死。我第一次尝试直接在shiny里渲染一个2万细胞 × 100基因的热图页面直接卡顿到没法操作浏览器内存直接飙到1个G以上。后来我做了数据压缩不是在前端做降采样而是在后端就把原始表达矩阵聚合成细胞类型 × 基因的均值矩阵或者更进一步把同类型的细胞用HDF5格式存储原始数据前端画热图时只加载聚合结果。这样前端拿到的数据量从几百万个数值压缩到几万个数值渲染流畅度提升非常明显。5.2 热图在前端的交互改造多了一个维度大屏场景下我不仅把静态热图搬上线还额外加了两个交互维度第一悬浮框显示详细信息。鼠标悬停在一个格子上显示细胞类型、基因名、平均表达量、表达比例、样本数量。这个信息密度是静态热图永远做不到的。第二点击基因联动其他图表。我用了ECharts和自定义图表组合点击热图上某个基因时下方趋势图和富集条目实时联动刷新——相当于把我前面说的复合视图搬到了线上而且以交互的形式呈现。前端热图我用ECharts的自定义series实现核心配置大概这样option { tooltip: { trigger: item, formatter: function(params) { // params.data包含基因名、细胞类型、表达值、pct等自定义信息 return ${params.data.gene}br/Celltype: ${params.data.ct}br/AvgExpr: ${params.data.avg}br/Pct: ${params.data.pct}%; } }, series: [{ type: custom, renderItem: function(params, api) { // 自定义格子渲染利用colorscale映射表达值 const x api.coord([api.value(0), 0])[0]; // 基因 const y api.coord([0, api.value(1)])[1]; // 细胞类型 const w api.size([1, 0])[0]; // 格子宽度 const h api.size([0, 1])[1]; // 格子高度 return { type: rect, shape: { x, y, width: w, height: h }, style: { fill: colorScale(api.value(2)) } // 颜色映射 }; }, dimensions: [gene, celltype, expr], data: heatmapData }] };5.3 大屏适配中的三个隐藏问题大屏适配是另一门艺术。我踩过的坑包括第一固定像素布局在大屏上缩放变形。后来统一用rem和vw/vh配合设计稿比例保证1920x1080和3840x2160下都能看。ECharts图表需要监听resize事件并且在大屏缩放时重新计算字体和格子大小。第二深色背景下的配色选择。大屏基本都是深色主题和论文热图的白底配色完全不同。白底用的红蓝配色在深色背景上对比度很差。我用的是RdBu调色板的微调版低表达用深蓝色高表达用亮红色并适当提高透明度和亮度。第三交互热图只适合展示关键基因不适合展示全转录组。大屏面向的观众对信息量的诉求没有科研那么高有人只会记住几个亮点。所以我在大屏上默认展示的是预先筛选好的top50-100个基因并通过按钮切换按通路过滤等模式让使用者自己探索。6. 改造工程里的为什么与最值线6.1 为什么我用ComplexHeatmap而不是ggplot2做主力这是很多新手的直接困惑。客观说ggplot2在普通数据可视化里更优雅但在热图这个领域ComplexHeatmap有两个不可替代的优势。第一是多图联动能力。ComplexHeatmap可以通过rowAnnotation、columnAnnotation、HeatmapList等机制把不同类型的热图和注释图叠加在一起实现前面说的复合视图。ggplot2虽然可以用patchwork拼接但在行名对齐和热图列名同步这些细节上麻烦得多要手动处理度量单位和坐标对齐。第二个是分面热图能力。单细胞数据经常需要按细胞类型分块展示ComplexHeatmap的column_split可以直接指定分组并在分组之间画出分隔线、加入分组的注释块同时保持所有分块的颜色映射一致。ggplot2用facet_grid也能实现类似效果但颜色条和图例的处理不如ComplexHeatmap灵活。所以我的建议是如果只是画一两张简单热图用什么都可以如果像我一样做复合视图改造直接在ComplexHeatmap这棵树上提高效率最有效。6.2 聚类顺序的稳定性和为什么尽量关掉行列聚类传统热图的聚类是自动的但自动聚类在单细胞场景下有两个问题一是每次运行hclust结果有细微波动尤其是基因很多时矩阵规模大算法对不同初始顺序的敏感性高二是自动聚类给出的顺序很难实现我要把通路A的基因放在一起这样的生物学设定。所以在改造中我倾向于预先定义好基因的顺序——按照通路分块比如EMT相关基因、细胞周期相关基因、免疫检查点相关基因然后组内再按平均表达模式或已知时间顺序排列最后直接指定这一行顺序给ComplexHeatmap禁止它再做聚类。这样虽然损失了一点点数学最优顺序但换来了极大的稳定性和可解释性。唯一例外如果做的是全转录组一大张热图上千个基因行数太多没法手动指定顺序我还是会保留聚类但聚类方法我倾向于用ward.D2而不是默认的complete。ward.D2生成的簇更紧凑、更符合生物直觉这是我对比多个数据集后的经验。6.3 颜色映射的隐蔽杀招处理0值比重过高的问题单细胞表达矩阵稀疏度非常高相当一部分基因在多数细胞类型里的表达为0。如果用Z-score标准化0会被映射成负值低于均值导致整张热图蓝汪汪一片。这里有三种典型处理方式方式一矩阵先做log1plog(expr1)再做按行Z-score。这是最常见流程但对稀疏矩阵来说log1p后的0还是大量存在Z-score会把0映射成偏负的值导致大片的蓝色区域成了视觉噪音。方式二只在非零值之上做标准化即伪Z-score。具体做法是把每一行基因、每种细胞类型的均值计算改成只计算表达该基因的细胞的均值比例高的和不表达的分开处理。这种方法能减少0值的干扰但计算起来比较绕。方式三使用覆盖率来作为热图的主要视觉维度。这就是我在第2节讲的圆点叠加方案——颜色表达强度大小表达覆盖率。我的推荐是如果矩阵密度比较均匀比如做了基因过滤之后用方式一截断就够了如果矩阵特别稀疏比如展示全部基因而不是top高变基因建议直接用方式三不要硬凹Z-score。6.4 热图导出与排版中容易忽略的清晰度问题最后一条经验是关于导出的。很多人画好热图直接用ggsave默认参数导出结果在论文/大屏里被压缩到看不清基因名。我的经验是导出时优先用PDF矢量格式pdf()设备这样缩放到任意尺寸都不会糊。在需要位图的场景比如网页预览用png但res要调到300以上同时width和height按实际展示区域的比例设定而不是随便给个默认值。具体到ComplexHeatmap的导出有个小技巧先通过draw()函数绘制再配合pdf()和dev.off()可以保证多图层的对齐关系不出错。如果你发现某一层的图例被切掉一部分试着调整heatmap_legend_side和annotation_legend_side参数不要直接硬调画布大小因为ComplexHeatmap会根据图例位置和大小自动重排整个绘图区域手动改画布尺寸很容易造成比例失衡。7. 改造完成后再看热图在单细胞分析中的定位这轮根本改造做完之后我对热图的态度有了明显转变。之前觉得热图就是个标准件——跑完数据、我画一张、贴进报告里就算交差。现在我觉得热图更像是一个叙事框架框架搭得好不好直接决定你的数据能不能被正确解读。举个例子同样是展示巨噬细胞亚群的marker基因改造前的热图只能告诉观众这几个亚群表达几个不同的基因但改造后的复合视图能告诉你S100A8和S100A9在炎症性巨噬细胞里特异性高表达而且覆盖率极高说明它们是这个亚群的稳定标记同时这两个基因富集在中性粒细胞脱颗粒通路而这些巨噬细胞恰好浸润在炎症组织区域——生物学故事一下就立体了。最后分享一个我自己的小原则任何可视化改造先问自己要表达的核心信息是什么再决定用什么图形。热图改造不是把所有酷炫效果堆上去而是为了把复杂数据讲清楚。如果你在某个需要展示的节点上发现热图已经承载不了你的信息那就是时候做改造了。希望这篇能给你一个可以下手的起点哪怕只改一个维度、换一种排列方式也比默认参数强。
返回列表