1. 先搞清楚一件事:坐标轴刻度"消失"还是"没画出来"
用scanpy画图时遇到坐标轴刻度问题,几乎每个人都会先怀疑是不是函数写错了。我自己刚开始也这样,sc.pl.tsne(adata, color="cell_type")敲下去,图出来了、点也着色了,但横轴纵轴连个数字都没有,甚至坐标轴的线和标签也全不见了。那会儿第一反应是去翻scanpy文档里有没有show_ticks=True这种参数,翻了半天也没找到,后来才意识到问题压根不在scanpy这一层。
这里有个很多人没理清楚的概念:scanpy的绘图函数,底层几乎全部走的是matplotlib。你调sc.pl.tsne、sc.pl.umap、sc.pl.dotplot,它内部做的事情是把你传进去的adata数据转换成matplotlib能理解的坐标数据,然后调用ax.scatter、ax.imshow之类的方法去画。所以刻度显示不显示、长什么样、朝内朝外、要不要科学计数法,最终都由matplotlib的坐标轴对象(Axes)决定。scanpy只是帮你生成了一些默认配置,这些默认配置里并没有专门暴露一个"显示刻度"的开关。
那刻度不显示一般是什么原因?我总结下来主要有三种:
- 画布被设置成隐藏坐标轴:某些绘图流程里,或者你自定义画图函数复用scanpy的输出对象时,调用了
ax.axis("off")或者ax.set_axis_off()。scanpy有的图(比如部分热图、dotplot)为了美观会主动关掉坐标轴,这个后面细说。 - 刻度画了,但被图例、色条或者别的元素"挤没"了:特别是当你用
sc.pl.umap(..., color=很多个分组变量)生成多子图时,每个子图的布局紧凑,刻度标签容易被裁切,看起来就像没有刻度。 - 刻度数字存在,但颜色和背景太接近:比如在深色背景下画了默认黑色刻度文字,或者刻度线宽太细,肉眼很难分辨。
所以遇到"刻度不显示",第一件事是判断它是"真的没画"还是"画了看不清"。怎么判断?直接在scanpy绘图后把当前Figure对象和Axes对象拿出来检查一下。拿我之前调的一个例子来说:
import scanpy as sc import matplotlib.pyplot as plt adata = sc.read_h5ad("your_data.h5ad") sc.pl.umap(adata, color="leiden", show=False) ax = plt.gca() # 获取最近一次绘图的坐标轴对象 # 检查坐标轴状态 print("x轴可见性:", ax.xaxis.get_visible()) print("y轴可见性:", ax.yaxis.get_visible()) print("x轴刻度位置:", ax.get_xticks()) print("当前坐标轴开关状态:", ax.axison)如果打印出来x轴可见性是False,那就是坐标轴被显式关闭了;如果get_xticks()返回空数组,说明压根没设置刻度;如果返回了一堆数字但图上看不到,那就是样式问题。用这种方法先把问题定位清楚,后面再动手调参数,效率完全不一样。
2. 传参直接改刻度:matplotlib层的通用做法
既然scanpy最终落在matplotlib上,那么最直接有效的方案就是在调用scanpy函数时,把你需要调整的matplotlib坐标轴参数以关键字参数的形式传进去。scanpy绝大多数画图函数都支持**kwargs透传,也就是说你写在sc.pl.umap(...)里的额外参数,最后会传给内部的matplotlib绘图方法。
我们来看一个最常见的需求:我只想正常显示坐标轴和刻度数字,其他什么都不动。那只需要:
import scanpy as sc adata = sc.read_h5ad("your_data.h5ad") sc.pl.umap(adata, color="leiden", show=True)如果你是正常用jupyter或者IDE弹窗画图,上面这个代码默认就是带刻度的。如果别人的代码里画出来没刻度,多半是在前面某处执行过全局设置:
import matplotlib.pyplot as plt plt.rcParams["axes.spines.top"] = False plt.rcParams["axes.spines.right"] = False # 或者更暴力地设置 plt.rcParams["xtick.bottom"] = False plt.rcParams["ytick.left"] = False这样设置之后,scanpy后续所有图都不显示刻度了。这时候不用去改scanpy的代码,只需把全局配置改回来:
plt.rcParams["xtick.bottom"] = True plt.rcParams["ytick.left"] = True plt.rcParams["xtick.labelbottom"] = True plt.rcParams["ytick.labelleft"] = True然后重新绘图即可。
如果你想更精细地控制刻度样式,比如隐藏上边和右边的刻度线、只保留左和下、并让刻度数字尽量精简,可以结合tick_params操作。我给一段比较完整的示例,把几个常用参数都覆盖到:
import scanpy as sc import matplotlib.pyplot as plt adata = sc.read_h5ad("your_data.h5ad") fig, ax = plt.subplots(figsize=(6, 5)) sc.pl.umap(adata, color="leiden", ax=ax, show=False) # 精细调整刻度 ax.tick_params( axis="both", # 对x和y轴统一调整 which="major", # 只调主刻度 direction="out", # 刻度线朝外 length=4, # 刻度线长度 width=1.2, # 刻度线宽度 labelsize=10, # 刻度数字字号 labelcolor="black", # 刻度数字颜色 top=False, # 关闭顶部刻度 right=False, # 关闭右侧刻度 ) # 隐藏上侧和右侧的坐标轴线 ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) plt.tight_layout() plt.show()这里解释一下几个容易踩坑的点:
ax.tick_params(top=False, right=False)关掉的是上边和右边的刻度线,不是边框线。边框线要单独通过ax.spines["top"].set_visible(False)去控制。很多人以为关掉右边的刻度线,右侧边框也没了,其实不是的,这两件事是独立的。direction="out"会把刻度线朝外画,坐标轴数字在更外面。基因组学、单细胞领域很多文章偏好这种风格,看起来干净。labelsize控制的是刻度数字的字号,ax.set_xlabel("UMAP1")这里面标签的字号要用ax.xaxis.label.set_size()或者ax.set_xlabel("UMAP1", fontsize=12)去调,两者不是一个属性。
3. 专门处理散点图的坐标轴范围:为什么说"显示刻度"不只是开关问题
在scanpy最常见的散点图场景里(UMAP、tSNE、PCA投影、UMAP colored by genes这种),刻度不显示只是问题之一,比这更常见的是"刻度显示了,但分布不均匀、范围奇怪、或者负坐标让图看起来很难受"。
比如UMAP结果里,有的批次数据x轴范围是-12 到 10,有的跑到-3 到 5,如果你的分析流程里不需要精确的相对距离(事实上UMAP的坐标本身没有绝对意义),那刻度显示反而会误导人。我看很多发表在文章里的UMAP图,直接就不显示刻度,但坐标轴标签 "UMAP1" "UMAP2" 保留。这样别人知道这是UMAP维度,不会被具体的数值分心。
所以在"画图显示坐标轴刻度"这个问题上,还有一个决定要做:到底要不要显示刻度数字?怎么决定?我的经验是:
- 如果你要展示的是分组分布(比如cell type、sample、batch),建议保留轴标签、弱化甚至去掉刻度数字,读者关心的是哪个群在哪、是否分离,不是具体坐标值。
- 如果你要展示的是基因表达量(color 一个连续性基因),那刻度数字很重要,因为色条和坐标轴一起才能说明表达水平。
- 如果你要比较多个样本的UMAP结构是否一致,那就需要保证所有子图的坐标轴范围一致,这时候刻度能不能显示、范围是否一致就很关键,因为读者需要一条视觉基线。
那怎么在需要时强制坐标轴范围一致?用ax.set_xlim/ax.set_ylim就行:
import scanpy as sc import matplotlib.pyplot as plt adata = sc.read_h5ad("your_data.h5ad") # 先画一个图获取坐标轴范围 sc.pl.umap(adata, color="leiden", show=False) ax = plt.gca() xlim = ax.get_xlim() ylim = ax.get_ylim() plt.close() # 用统一范围绘制子图 fig, axes = plt.subplots(1, 3, figsize=(15, 5)) for i, sample_id in enumerate(adata.obs["sample"].cat.categories): adata_sub = adata[adata.obs["sample"] == sample_id] sc.pl.umap(adata_sub, color="leiden", ax=axes[i], show=False) axes[i].set_xlim(xlim) axes[i].set_ylim(ylim) axes[i].set_title(sample_id) plt.tight_layout() plt.show()这一招在做批次效应评估时特别好用。我之前处理一个多批次合并的数据,直接用默认范围画,每个批次各自缩放到自己的坐标范围,看起来结构差异特别大,实际上只是坐标尺度不同,把范围统一之后真相大白——其实聚类结构挺一致的。
再来一个细节:set_xlim设置的范围会改变刻度显示,但不影响散点本身的位置。如果你画完子图发现某个样本的坐标值超出统一范围,matplotlib默认不会自动裁剪散点,而是让点"溢出"画布之外,这时你可能需要加一句:
axes[i].set_xlim(xlim[0] - 1, xlim[1] + 1) axes[i].set_ylim(ylim[0] - 1, ylim[1] + 1)多留出1个单位左右的padding,防止边缘的点被坐标轴截断。
4. 文本类刻度怎么处理:当横坐标不是数值而是细胞类型或基因名
你以为只有连续数值才有刻度问题吗?其实在scanpy里,有一类图特别容易在刻度上翻车:横轴或纵轴是类别型标签的图。比如sc.pl.dotplot(adata, var_names=["GeneA", "GeneB"], groupby="leiden"),横坐标是细胞类型名,纵坐标是基因名。这种图的刻度标签是文本,字体大小、旋转角度、标签长度稍微一不对,整张图就糊了。
这里最常见的两个问题:
第一个:标签太长,互相重叠。尤其是细胞类型命名为 "CD8+ T cell subset-1" 这种长字符串时,默认水平排列的xticklabels会全挤在一起。解决方式很直接,旋转角度:
ax = plt.gca() plt.xticks(rotation=45, ha="right", fontsize=9)ha="right"(horizontal alignment)用来让文本右对齐,这样旋转后文字不会超出画布边缘。45度是一个比较保险的角度,60度也可以,但可读性会下降一点。
第二个:类别太多,刻度全显示,密密麻麻一片。假设你groupby的是一个有30个类别的列,dotplot横坐标就会列出30个刻度标签,图宽固定时必然重叠。这种情况我一般用两种手段处理:
- 手动指定显示哪些刻度,其余隐藏:
import matplotlib.ticker as ticker ax = plt.gca() # 只保留第0、5、10、15、20、25个刻度及其标签 ticks_to_show = [0, 5, 10, 15, 20, 25] ax.set_xticks(ticks_to_show)- 或者保留所有刻度位置,但每隔几个才显示标签:
ax.set_xticks(range(len(categories))) ax.set_xticklabels(categories) for idx, label in enumerate(ax.get_xticklabels()): if idx % 5 != 0: label.set_visible(False)这个方法的好处是刻度线还在,不会让人觉得坐标轴空了一块,只是标签稀疏化,视觉上干净很多。
还有一类容易被忽略的是热图类绘图。sc.pl.heatmap(adata, var_names=genes, groupby="leiden")默认会把横坐标的基因名和纵坐标的细胞类别的刻度标签全部画出来。多数情况下基因名一多,图就变了"黑条条"。我对热图的建议是:只显示少量有代表性的基因名标签,或干脆不显示刻度标签,在图上用注释块标注基因模块即可。要显示的话,记得把标签字体调小:
sc.pl.heatmap( adata, var_names=["GeneA", "GeneB", "GeneC"], groupby="leiden", show=False, ) ax = plt.gca() plt.setp(ax.get_xticklabels(), fontsize=7, rotation=90) plt.setp(ax.get_yticklabels(), fontsize=7)5. 利用ax对象深度修改:从scanpy手里拿回绘图控制权
上面提到过几次ax = plt.gca(),其实这是解决scanpy刻度问题的万能钥匙。scanpy的问题是封装程度高,好多参数藏得深,但你只要把ax对象拿到手,matplotlib支持的对刻度、坐标轴、文字、颜色的一切操作你都能做。
我强烈建议,凡是涉及"必须精修刻度"的图,不要在scanpy声明式写法里死磕,而是统一采用"fig + ax"的显式写法。拿UMAP来举例,你完全可以这样做:
import scanpy as sc import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator, FormatStrFormatter adata = sc.read_h5ad("your_data.h5ad") fig, ax = plt.subplots(figsize=(6, 5)) sc.pl.umap( adata, color=["leiden"], ax=ax, show=False, title="UMAP colored by Leiden clusters", frameon=True, # 确保坐标轴边框存在 ) # 开始逐项接管坐标轴刻度 ax.xaxis.set_major_locator(MaxNLocator(nbins=5)) # x轴最多5个刻度区间 ax.yaxis.set_major_locator(MaxNLocator(nbins=5)) ax.xaxis.set_major_formatter(FormatStrFormatter("%.1f")) # 保留一位小数 ax.yaxis.set_major_formatter(FormatStrFormatter("%.1f")) # 刻度只显示在左和下 ax.tick_params( axis="both", which="major", direction="in", length=3, width=1, labelsize=8, top=False, right=False, ) # 坐标轴标签 ax.set_xlabel("UMAP1", fontsize=10) ax.set_ylabel("UMAP2", fontsize=10) plt.tight_layout() plt.show()这里面MaxNLocator(nbins=5)的意思是让matplotlib自动在坐标轴范围内选取最多5个刻度区间,也就是大概6个刻度线,数字会比默认的少很多,图形更清爽。FormatStrFormatter控制刻度数字格式,比如保留一位小数,这样不会出现-12.345678这种丑到爆的刻字。
如果不想用百分比、不想用科学计数法,也可以直接用FuncFormatter做自定义格式化,比如统一加上单位:
from matplotlib.ticker import FuncFormatter def log2_formatter(value, pos): return f"{value:.0f}" ax.xaxis.set_major_formatter(FuncFormatter(log2_formatter))这种自由度是scanpy直接写所给不了的。所以,记住一个关键判断:scanpy负责画数据,matplotlib负责画坐标轴。一旦你把ax拿到手,坐标轴的命脉就握在你手里了。
另一个可以深度利用点是双坐标轴。在某些图里你可能希望右侧或者顶部也显示刻度参考,scanpy自己没这功能,但我们可以拿ax对象手动加:
ax2 = ax.twiny() # 共享y轴,创建顶部x轴 ax2.set_xlim(ax.get_xlim()) ax2.set_xticks(ax.get_xticks()) ax2.set_xticklabels([f"{v * 2:.1f}" for v in ax.get_xticks()]) ax2.set_xlabel("Doubled scale")这个技巧在展示变换后的坐标(比如原始count和标准化后count对照)时比较有用。
6. 灰度图、小倍数图与导出时容易忽略的刻度细节
最后这部分,我来梳理几个特别容易在"刻度"问题上翻车的边角场景,都是我实际踩过的:
场景一:灰度图或单色图的坐标轴刻度看不清。scanpy的sc.pl.umap(color="gene_expression")在黑色背景下,默认刻度数字是黑色,看起来费劲;浅色背景下又可能太白。解决办法是统一指定刻度颜色,或者反过来给图片加一个浅色边框。更通用的是直接在tick_params里设:
ax.tick_params(colors="black") # 统一刻度线、刻度数字和坐标轴线的颜色用colors参数而不是分别设置labelcolor、color,可以少写几行。
场景二:多子图里某个子图的刻度标签被裁切。sc.pl.umap(adata, color=[多个变量], ncols=3)生成了多子图之后,最后一个子图的y轴刻度标签经常被切掉一半。原因是用plt.tight_layout()时,有些子图的边界没考虑刻度标签宽度。解决办法是别偷懒,给足边距:
plt.tight_layout(pad=2.0) # 或者用更保底的 plt.subplots_adjust(left=0.1, right=0.95, top=0.95, bottom=0.1)如果还不行,就改用constrained_layout=True创建画布:
fig, ax = plt.subplots(figsize=(8, 6), constrained_layout=True)constrained_layout比tight_layout在复杂子图下表现更稳定,不会出现某些子图标签被吞的情况。
场景三:保存图片时刻度数字糊成一片。你屏幕上看着挺清楚的图,plt.savefig出来之后数字糊了、刻度线丢失。这个跟DPI有关。savefig的时候如果不指定dpi,会继承画布的默认DPI(通常是100),屏幕上看刚好,打印出来就虚,尤其是坐标轴上的小字。建议保存图片时显式指定DPI:
fig.savefig("umap_ticks.png", dpi=300, bbox_inches="tight")bbox_inches="tight"会重新计算边界,把坐标轴标签完整纳入到保存范围内,防止保存出来的图四边被裁剪。dpi=300是投稿级别的分辨率,刻度文字印刷出来也清清楚楚。
场景四:PDF或SVG矢量图导出后刻度字体变了。如果保存成PDF,再在AI或Inkscape里打开,有时候刻度数字会变成默认字体或者位置出现偏移。这个严格说不是scanpy的问题,是matplotlib在转存时字体嵌入策略导致的。解决方法是提前配置matplotlib的字体回退:
import matplotlib matplotlib.rcParams["pdf.fonttype"] = 42 # TrueType字体,而不是Type 3 matplotlib.rcParams["svg.fonttype"] = "none" # SVG里保留文本而不是转路径pdf.fonttype=42对投稿很重要,很多期刊要求文本必须是TrueType或嵌入子集,否则编辑打开时会乱码或字体缺失。svg.fonttype="none"会让保存的SVG文件里的文字保持可编辑状态,方便后期在矢量编辑软件里微调。
7. 给一份可以直接抄作业的完整配置模板
说了这么多,我最后整理一份我平时最常用的scanpy坐标轴刻度配置模板。这份模板经过多轮实际项目验证,基本覆盖了"要刻度、要好看、要能投稿"的所有需求。
import scanpy as sc import matplotlib.pyplot as plt from matplotlib.ticker import MaxNLocator, FormatStrFormatter # 全局统一设置 plt.rcParams["figure.dpi"] = 120 plt.rcParams["savefig.dpi"] = 300 plt.rcParams["font.size"] = 9 plt.rcParams["axes.linewidth"] = 0.8 plt.rcParams["xtick.major.width"] = 0.8 plt.rcParams["ytick.major.width"] = 0.8 adata = sc.read_h5ad("your_data.h5ad") def style_axis(ax, xlabel="UMAP1", ylabel="UMAP2"): """统一坐标轴样式:保留刻度但精简显示""" ax.xaxis.set_major_locator(MaxNLocator(nbins=4)) ax.yaxis.set_major_locator(MaxNLocator(nbins=4)) ax.xaxis.set_major_formatter(FormatStrFormatter("%.0f")) ax.yaxis.set_major_formatter(FormatStrFormatter("%.0f")) ax.tick_params( axis="both", which="major", direction="in", length=2, width=0.8, labelsize=8, top=False, right=False, ) ax.set_xlabel(xlabel, fontsize=10) ax.set_ylabel(ylabel, fontsize=10) return ax # 绘图 + 样式 fig, ax = plt.subplots(figsize=(5.5, 4.5)) sc.pl.umap( adata, color="leiden", ax=ax, show=False, title="", frameon=True, ) style_axis(ax) # 保存 fig.savefig( "umap_final.png", dpi=300, bbox_inches="tight", facecolor="white", )注意facecolor="white"这个参数:如果你的会话背景是深色,不设置背景色的话保存的图片背景可能也是深色,投稿人家不爱收。加上之后强制白色底。
用这份模板,你可以跑出一张刻度清晰、格式规整、没有多余干扰的UMAP图。要不要再进一步显示更多十进制位、要不要加网格线,那都看写作需求了。网格线可以用ax.grid(True, linestyle="--", linewidth=0.3, alpha=0.5)加上,但多数单细胞图我不建议加,散点密集时网格线反而添乱。
最后再分享一个我自己的习惯:每完成一张要投稿的图,我会单独写一个plot_style.py文件,把坐标轴样式函数放进去统一调用。这样无论scanpy版本怎么升级、内部绘图函数怎么变动,我控制的坐标轴样式都不会被它的默认设置覆盖。这也是处理"scanpy画图显示坐标轴刻度"这类问题的最根本心法——别依赖它的默认值,自己定义一套样式,然后每次都显式应用。