☰
scanpy坐标轴刻度设置:从Axes对象到tick_params实战
2026/10/24 6:14:38 网站建设 项目流程

最近有朋友问了我一个看起来很小、但特别容易卡壳的问题:他用scanpy画了一批UMAP图,想在坐标轴上把刻度显示出来,结果怎么调都不对,不是刻度消失就是标签糊成一团。我帮他排查了一下,发现这个问题本身不复杂,真正容易踩坑的地方在于,很多人没搞清楚scanpy的绘图对象到底给了你什么,以及该回去改哪一层参数。这篇文章就把我在实际项目中处理scanpy坐标轴刻度的思路、代码和经验写下来,希望对正在纠结这个细节的人有帮助。

1. 先搞清楚:坐标轴刻度与坐标轴标签是两回事

1.1 你想要的到底是“刻度”还是“标签”

我见过太多人把两个概念混在一起说“显示坐标轴”,但实际上它们完全不是同一种东西。坐标轴标签,是坐标轴旁边写的文字,比如UMAP1、UMAP2,或者基因名;坐标轴刻度,是坐标轴上那些小短线以及旁边对应的数字,比如x轴上标着0、5、10、15这样的数值。

用scanpy画单细胞降维图时,经常出现的一种情况是:UMAP1、UMAP2这两个标签默认就在,但刻度线很淡,或者因为颜色和背景太接近,肉眼看不清;也有少数情况是刻度数字空空的,只留了几根短线。所以你在处理之前,先明确自己的真实需求是哪一个。想清楚这个,后面的操作边界才清晰。

1.2 scanpy的图本质上还是matplotlib的Axes

很多人以为scanpy的绘图是一个黑盒,其实不是。scanpy底层用matplotlib画图,每个子图在matplotlib里是一个Axes对象,所有的刻度、标签、图例都挂在这个对象上面。所以,你想显示扫描py图中的坐标轴刻度,本质上就是拿到对应的Axes对象,然后调用matplotlib的方法去改它。

难点在于,scanpy的封装让你平时不太容易拿到这个对象。很多教程直接写sc.pl.umap(adata),然后图就显示出来了,看起来好像什么都没返回。但你只要把show=False加进去,就能把背后那个Axes“截”到自己的变量里。这一步是整个问题的基础,下面的几步全依赖它。

1.3 为什么默认图里刻度“看不见”

我拆过scanpy的默认样式,它为了出图美观,会调用一套偏向“出版风格”的绘图参数。这套参数里,坐标轴刻度不是被删掉了,而是颜色被压得很浅,刻度长度也设置得很短,导致在不放大看的情况下几乎注意不到。另外,scanpy默认会保留坐标轴的上方和右方边框,而刻度可能只出现在下方和左侧,视觉上也会觉得“好像没刻度”。

这种情况不是bug,也不需要你去改scanpy源码。只要把返回的Axes拿出来,重新设置一次tick_params,把颜色改成深色、把长度加大,刻度自然就清楚了。后面我会给出具体代码。

2. 最基本的三步调法:拿到Axes后想怎么加就怎么加

2.1 用show=False把绘图对象“截下来”

想让scanpy图显示坐标轴刻度,第一步是拿到Axes。举个例子:

import scanpy as sc import matplotlib.pyplot as plt adata = sc.datasets.blobs() sc.pp.neighbors(adata) sc.tl.umap(adata) sc.tl.leiden(adata) ax = sc.pl.umap(adata, color='leiden', show=False)

这里有几个细节值得注意。如果只画一个子图,ax就是一个matplotlib的Axes对象;如果color传了一个列表,比如color=['leiden', 'bulk_labels'],那返回的可能是Axes组成的列表或数组,得用遍历的方式来处理。另外,show=False的含义是“先别把图画到交互窗口里,让我拿到对象后再自己控制什么时候展示”。

反过来,如果你在调用时直接传了ax=参数给scanpy,那返回值通常会变成None,因为图已经画到你传入的那个Axes上了,scanpy不把对象还给你。这种情况也很常见,尤其是自己用plt.subplots()创建了画布然后画多个图的时候。遇到返回None不用慌,去操作你最开始创建的那个ax就行。

2.2 手动设置刻度位置和标签

拿到Axes之后,刻度显示就完全是matplotlib的常规操作了。核心方法有三个:

  • ax.set_xticks(ticks):设置x轴刻度出现的位置。
  • ax.set_xticklabels(labels):设置刻度旁边显示的文字。
  • ax.tick_params(axis='x', **kwargs):设置刻度颜色、长度、粗细、字体大小等样式。

y轴同理,把x换成y即可。

对于UMAP、tSNE这种连续坐标轴,其实你不需要手动指定刻度位置,用matplotlib默认的自动刻度一般就够。真正需要做的通常是:把刻度颜色调深、把字体调大、把刻度调到合适密度。比如:

ax.tick_params(axis='both', colors='black', labelsize=10, length=4, width=0.8)

如果坐标轴上显示的是离散的文字标签,比如细胞类型、cluster编号,你想让刻度从1、2、3变成“CD4 T cells”、“B cells”,那就要用set_xticklabels手动映射。不过要注意,设置文字刻度前,必须先确定set_xticks的位置个数和标签个数一致,否则会出现标签错位的奇怪现象。

2.3 一个可以直接抄的小例子

下面给一个完整的、可以直接跑通的小示例。它最基础,也是最推荐的起步写法:

import scanpy as sc import matplotlib.pyplot as plt adata = sc.datasets.blobs() sc.pp.neighbors(adata) sc.tl.umap(adata) sc.tl.leiden(adata) ax = sc.pl.umap(adata, color='leiden', show=False) ax.set_xlabel('UMAP1') ax.set_ylabel('UMAP2') ax.tick_params(axis='both', which='major', labelsize=12, colors='black', length=5) plt.show()

这段代码的效果是:UMAP图正常画出来,坐标轴上会显示清晰的黑色刻度数字,字号也大了一些,打印出来也不至于“隐身”。which='major'表示只修改主刻度;如果你连图中比较细的副刻度也想控制,可以把which='both'。

我建议你把这个例子的每一步都跑一遍,然后试着把show=False去掉再看看,对比一下返回值的差异。这样你会真切理解scanpy的封装行为,后面遇到更复杂的嵌套布局就不会蒙。

3. 进阶定制:让刻度适配不同数据场景

3.1 连续型坐标轴:控制刻度范围和密度

单细胞数据的UMAP坐标,数值范围一般从负十几到正十几,不同数据集差异挺大。如果想让刻度看起来更“舒服”,可以用set_xticks配合numpy生成刻度序列。比如,我想让x轴从-10到15,每隔5个单位显示一个刻度:

import numpy as np ax.set_xlim(-10, 15) ax.set_xticks(np.arange(-10, 16, 5)) ax.set_yticks(np.arange(-10, 16, 5))

这种写法的好处是刻度范围完全由自己控制,方便多张图之间对比。比如你要把不同亚群的UMAP图画成拼图,又希望它们的坐标范围完全一致,那就先计算所有子图里x轴和y轴的最大最小值,统一set_xlim之后,再设置同一组刻度。

如果你不想手动写刻度序列,还可以用ax.locator_params控制自动刻度的密度:

ax.locator_params(axis='x', nbins=6)

nbins表示希望x轴上最多出现大约6个刻度。它是给matplotlib一个建议值,不是硬性规定,但实际调起来很顺手。刻度太密就用小数,太疏就加大,适合随手试。

另一个容易忽略的功能是刻度值的格式化。当坐标值很大或者很多小数位时,刻度数字会显得很难看。用ticklabel_format可以切换成科学计数法,或者用FormatStrFormatter强制保留两位小数:

from matplotlib.ticker import FormatStrFormatter ax.yaxis.set_major_formatter(FormatStrFormatter('%.1f'))

这样y轴刻度的数字就固定保留一位小数。

3.2 离散型刻度:防止长标签挤成一团

虽然UMAP图一般不会遇到字符串刻度,但scanpy里还有其他图会用到,比如dotplot、heatmap、stacked_violin。如果你用sc.pl.dotplot画了一个分组图,横坐标是细胞类型或cluster名,刻度标签往往是一长串文字,默认横排会导致重叠。

处理方式很简单:拿到对应的坐标轴后,把标签旋转一下。

ax.set_xticks(range(n)) ax.set_xticklabels(cell_types, rotation=45, ha='right', fontsize=9)

ha='right'的含义是让文字右对齐,配合旋转45度后,标签之间不容易“打架”。这种写法在scanpy的复合图中反而更常见,因为scanpy提供的默认处理不一定满足你的排版要求。

如果你发现标签数量太多,旋转了还是挤,那就考虑减少展示的类别,或者把画布尺寸调大。比如用fig = plt.figure(figsize=(10, 6))创建合适大小的画布,再画图。

3.3 用tick_params统一控制样式

很多刚接触matplotlib的人喜欢挨个设置刻度颜色、长度、字体,写起来非常啰嗦。其实tick_params一个方法就能统一处理:

ax.tick_params( axis='both', which='major', direction='in', length=6, width=1.2, colors='black', labelsize=11, top=True, right=True, )

这里direction='in'会让刻度线朝向图内,很多论文图喜欢这种风格;top=True和right=True表示把上边和右边的刻度线也显示出来。scanpy默认可能只显示下边和左边,你通过tick_params可以把四周都打开,看起来会更完整。

我这里特别想强调一点:tick_params是针对每个Axes进行设置,不是全局设置。如果你画了多个子图,每个子图都要调用一次。这听起来麻烦,但配合后面的批量处理就能解决。

3.4 多子图场景下的批量处理

实际分析中,很少有人只画一张UMAP图。更常见的是:

axs = sc.pl.umap(adata, color=['leiden', 'bulk_labels', 'phase'], ncols=3, show=False)

这样返回的axs是一个数组,可能是list,也可能是numpy.ndarray。我建议统一把它转成数组再遍历:

import numpy as np axs = np.atleast_1d(np.array(axs).ravel()) for ax in axs: ax.tick_params(axis='both', labelsize=10, colors='black') ax.set_xlabel('UMAP1') ax.set_ylabel('UMAP2')

如果ncols大于1,返回的轴可能是一个二维数组,ravel()能把它拉平变成一个一维列表,遍历起来最省心。

还有一个经验:多子图时,你通常希望所有子图坐标范围一致,这样不同基因或不同分组的细胞分布可以平行比较。做法是先收集所有子图的范围,再统一设置:

xlims = [ax.get_xlim() for ax in axs] ylims = [ax.get_ylim() for ax in axs] xlim = (min([x[0] for x in xlims]), max([x[1] for x in xlims])) ylim = (min([y[0] for y in ylims]), max([y[1] for y in ylims])) for ax in axs: ax.set_xlim(xlim) ax.set_ylim(ylim)

这样处理完,所有子图站在同一个坐标空间里展示,比每张图各调各的刻度直观得多。

4. 常见问题排查:刻度消失、重叠、乱跳

4.1 刻度太淡或完全看不见

这种问题最常见。其实不是没有刻度,而是刻度颜色太浅,尤其在一张浅色背景下,灰色刻度根本看不出来。解决方法是直接指定颜色:

ax.tick_params(axis='both', colors='black')

如果你在显式调用ax.set_xticks([])后想恢复,也是同样操作,重新把刻度位置设置成想要的数值数组即可。

还有一种情况是:用sc.pl.umap(..., show=True)绘图后,再想去修改刻度,发现怎么也改不了。这是因为show=True的执行顺序比较靠前,图已经渲染并展示过了,后面的修改不会实时更新。正确做法是始终保持show=False,改完所有参数后统一用plt.show()显示。

4.2 标签重叠成一片

字符串刻度重叠,一般从两个方向排查。一是刻度太密,导致文字挤在一起;二是文字太长且方向是水平排布。优先减小密度或旋转标签。如果是数字刻度重叠,通常是因为数据范围跨度太大、自动刻度选择了太多位置。比如坐标从-0.001到0.001,matplotlib默认可能生成很多小数刻度。这时用locator_params(nbins=5)强制减少刻度数量。

如果是heatmap这类图里横轴基因名很多,我推荐旋转并缩小字号,而不是强行删减刻度标签。删掉一些标签会让读者找不到对应基因,反而影响表达。

4.3 刻度范围与数据对不上

有时候你会遇到这样的问题:图上的细胞明明分布在-5到5之间,但坐标轴刻度却显示到-20到20。这通常是因为set_xlim被改过,或者你用了某个全局配置把坐标范围固定了。排查时先看当前坐标范围:

print(ax.get_xlim(), ax.get_ylim())

确认范围后,用ax.set_xlim和ax.set_ylim调整到数据实际范围。还有一个更自动的办法:让matplotlib完全自动计算范围,直接调用ax.autoscale(enable=True, axis='both')。

如果你是用sc.pl.umap画图,并且传入了color是连续型的基因表达量,那么坐标轴和数据点的映射是正常的,刻度也应该与表达量对应。如果出现明显不匹配,最常见的原因是自己改了vmin/vmax以外的参数,比如误改了ax.collections的clim。

4.4 heatmap/dotplot这类复合图的坐标轴要怎么拿

scanpy不是所有绘图函数都返回单个Axes。sc.pl.heatmap、sc.pl.dotplot这类复合图返回的是封装对象,里面包含多个子图,比如主热图、侧边聚类、颜色条,每个部分都有自己的坐标轴。获取方式因scanpy版本而异,我比较常用的两种:

res = sc.pl.heatmap(adata, var_names=genes, groupby='leiden', show=False) # 方法1:直接访问轴属性 heatmap_ax = res['heatmap_ax'] # 方法2:老版本可能返回元组 # heatmap_ax = res[0]

遇到这种情况,先别急着猜,直接用type(res)、dir(res)看结构。我最常用的是打印res.keys()(如果是字典),或者访问res.ax_dict。确认坐标轴变量名后,再去操作它的刻度。

因为官方API在新版本里会把复合对象重构得越来越清晰,所以代码里最好加一个容错判断:

try: heatmap_ax = res['heatmap_ax'] except TypeError: heatmap_ax = res[0]

这种写法在不同版本间切换时更安全。

5. 实操心得:把坐标轴刻度做成可控的习惯

5.1 全局配置:设置matplotlib参数而非每个图单独改

如果你整个项目所有的统一设置,强烈建议在代码开头统一配置matplotlib.rcParams。这样就不用每个图都去手动tick_params了。比如:

import matplotlib as mpl mpl.rcParams['xtick.color'] = 'black' mpl.rcParams['ytick.color'] = 'black' mpl.rcParams['xtick.labelsize'] = 10 mpl.rcParams['ytick.labelsize'] = 10 mpl.rcParams['xtick.direction'] = 'in' mpl.rcParams['ytick.direction'] = 'in'

这样配置后,所有基于matplotlib的图,包括scanpy画出来的图,坐标轴刻度都会使用这些默认样式。

需要注意的是,如果在配置之后又调用了sc.set_figure_params(),scanpy内部可能会覆盖一部分rcParams。顺序上,应该把sc.set_figure_params放在前面,然后再单独改matplotlib的rcParams,确保自己的参数优先级更高。

5.2 一个简化刻度的helper函数

项目里图一多,写大量重复的tick_params不仅页面难看,还容易出现遗漏。我个人习惯是把“统一设置坐标轴刻度”的逻辑写成一个helper函数,放到项目共用工具文件里:

def beautify_ax(ax, labelsize=10, tickcolor='black', show_ticks=True): if not show_ticks: ax.set_xticks([]) ax.set_yticks([]) else: ax.tick_params(axis='both', which='major', labelsize=labelsize, colors=tickcolor, length=5, width=0.8) ax.spines['top'].set_visible(False) ax.spines['right'].set_visible(False) return ax

画完scanpy图后,直接传ax进去调用:

ax = sc.pl.umap(adata, color='leiden', show=False) beautify_ax(ax)

这样全篇论文图的刻度风格就能保持一致。想隐藏坐标轴时,只需要show_ticks=False这一个开关。

5.3 保存图片时不要让刻度标签被裁掉

一个很容易被忽略的坑是:图本身的尺寸是够的,但保存图片时bbox_inches设置不当,导致坐标轴刻度标签被裁掉一半。用savefig时,我一般会加上bbox_inches='tight':

plt.savefig('umap_with_ticks.png', dpi=300, bbox_inches='tight')

这样保存时会自动根据内容扩展画布边界,刻度数字就不会切边。如果保存的是PDF,也可以同样设置。对投稿论文来说,这一点非常实用,因为审稿人真的会放大每一张图看坐标刻度。

5.4 我的一个小习惯

最后分享一个我自己的习惯:画scanpy图时,永远带上show=False,除非我只是临时在notebook里瞟一眼。认真要用的图,都拿到Axes对象之后再组装。这个习惯帮我省掉了很多返工时间。

如果你发现某张scanpy图总是“没有坐标轴刻度”,先执行ax.get_xticks()看看是否有值。如果返回空数组,多半是刻度被清空了;如果返回了一堆数字但看不清,那就是样式问题。两种情况的处理方向完全不同,不要上来就一顿乱设。

这个看似简单的需求,背后其实藏着matplotlib整个坐标轴的体系。只要掌握了“拿到Axes再操作”这个核心思路,显示坐标轴刻度这件事就再也不会成为麻烦。

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

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

立即咨询