Python数据可视化:使用adjustText库解决散点图标签重叠问题
2026/8/28 3:38:42 网站建设 项目流程

1. 项目概述:当散点图标签“打架”时,我们该怎么办?

做数据分析或者数学建模的朋友,肯定没少跟散点图打交道。一张清晰的散点图,配上精准的数据点标签,是展示数据分布、聚类情况或者异常点的利器。但麻烦事儿往往就出在这个“标签”上——当你手头的数据点成百上千,或者即便只有几十个但分布密集时,直接调用matplotlibtextannotate功能添加标签,结果十有八九会是一场灾难。所有标签挤在一起,互相重叠,密密麻麻像一锅粥,根本分不清谁是谁,信息传递的效率几乎为零。我最近在复现一个关于城市经济指标分析的模型时,就深刻体会到了这种痛苦:30个城市的名字在图上扭打成一团,完全失去了可视化的意义。

这个问题的核心,就是标签文本的自动布局与避让。我们手动调整xytext参数当然可以解决一两个点的重叠,但对于大批量数据,这无异于愚公移山。好在Python生态里从来不缺解决问题的轮子,adjustText库就是专门为攻克这个难题而生的。它不是一个绘图库,而是一个智能的“标签排版引擎”,能自动计算标签与数据点、标签与标签之间的位置关系,通过迭代算法微调每个标签的位置,直到找到一个彼此不重叠、且尽可能靠近其对应数据点的“和谐”布局。

简单来说,adjustText能帮你把散点图上那些“打架”的标签一个个拉开,让图表瞬间变得清爽可读。这对于需要向论文、报告或演示文稿提交高质量图表的数学建模、科研分析、商业智能等领域的工作者来说,是一个能极大提升效率和专业度的工具。接下来,我就结合自己的踩坑经验,带你从原理到实战,彻底掌握这个利器。

2. 核心原理与方案选型:为什么是adjustText?

在深入代码之前,我们得先弄明白adjustText到底是怎么工作的,以及为什么在众多方案中它脱颖而出。标签重叠本质上是一个优化问题:在二维平面上,我们有一系列固定点(数据点)和一系列需要移动的文本框(标签),目标是找到一组标签位置,使得所有标签都不相交,并且每个标签与其对应数据点的距离尽可能短。

2.1 常见的“土办法”及其局限

在遇到adjustText之前,我和很多人一样,尝试过各种手动或半自动的方法:

  1. 手动调整annotatexytextarrowprops:对于几个点的演示图还行,数据量稍大就完全不可行,耗时耗力且效果难以保证。
  2. 旋转标签角度:使用rotation参数让标签倾斜。这在一定程度上能减少横向的重叠,但对于密集区域,标签依然会像叠罗汉一样堆在一起,可读性反而更差。
  3. 只标注部分点:通过条件判断,只给某些特定的点(如最大值、最小值、离群点)添加标签。这确实是一种实用的策略,但它牺牲了信息的完整性。在很多需要全面展示所有样本的建模场景中,这并不适用。
  4. 调整图形尺寸或DPI:把图拉得非常大,或者输出极高分辨率的图片。这本质上是“物理稀释”重叠,但会导致图片文件巨大,在文档中排版困难,并且放大观看时,重叠问题依然存在。

这些方法都只是缓解症状,而非根治问题。我们需要的是一个能自动、智能、全局优化标签位置的算法。

2.2 adjustText的智能避让算法

adjustText库的核心是一个迭代力导向算法。你可以把它想象成标签之间、标签与数据点之间存在着“力”:

  • 排斥力:存在于任意两个标签之间,以及标签与非对应数据点之间。当它们靠得太近(发生重叠或距离小于阈值)时,会产生一个将它们推开的力。
  • 吸引力:存在于每个标签与其对应的数据点之间。这个力试图将标签拉回它的“锚点”,防止标签在避让过程中飞得太远。

算法初始化时,所有标签通常被放在对应数据点的正上方(或默认位置)。然后,在每一次迭代中:

  1. 计算所有标签受到的合力(吸引力 + 所有相关的排斥力)。
  2. 根据合力方向,将每个标签移动一小步。
  3. 检查重叠情况。如果仍有重叠,则继续迭代;如果所有标签都已分离,或达到最大迭代次数,则停止。

这个过程类似于模拟一个物理系统逐渐达到平衡状态,最终得到一个局部最优的布局。adjustText提供了丰富的参数让你调节这些“力”的大小、作用范围以及迭代策略,以适应不同的图表风格和密度。

注意adjustText解决的是静态布局问题。它针对当前视图(x轴和y轴的范围)计算最优位置。如果你之后用plt.xlimplt.ylim改变了坐标轴范围,标签位置不会自动更新,可能会再次重叠。所以,务必先设定好坐标轴范围,再调用adjustText

2.3 与其他工具的对比

除了adjustText,社区里还有一些其他尝试,比如maplotliboffsetbox模块(AnnotationBbox)或某些专门的标签布局算法。但adjustText的优势在于:

  • 专一性:它只解决标签重叠问题,接口非常简洁(核心就是一个adjust_text函数)。
  • 智能化:其力导向算法在实践中表现出了很好的鲁棒性,对中等密度的标签群效果显著。
  • 可定制性:参数丰富,可以精细控制避让的强度、方向偏好、迭代步长等。
  • 社区活跃:作为一个有一定历史的库,它遇到的问题和解决方案比较丰富,容易找到参考案例。

因此,对于绝大多数Python绘图场景下的标签重叠问题,adjustText是目前最成熟、最直接的首选方案。

3. 环境准备与基础用法

3.1 安装adjustText库

安装过程非常简单,通过pip即可完成。建议在虚拟环境中操作,以避免依赖冲突。

pip install adjustText

这个库的依赖很少,主要是matplotlibnumpy,如果你已经在做数据可视化,这些环境肯定已经具备了。

3.2 一个最简单的例子:从混乱到清晰

让我们用一个极端的例子,直观感受一下adjustText的魔力。假设我们有10个数据点,它们的标签就是简单的字母,但y值非常接近。

import matplotlib.pyplot as plt from adjustText import adjust_text import numpy as np # 1. 创建模拟数据:10个点,y值密集 np.random.seed(42) x = np.arange(10) y = np.random.rand(10) * 0.2 + 5.0 # y值在5.0到5.2之间,非常密集 labels = [f'Point_{i}' for i in range(10)] # 2. 绘制散点图 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) # 子图1:未处理的重叠标签 ax1.scatter(x, y, color='blue', zorder=5) for i, txt in enumerate(labels): ax1.annotate(txt, (x[i], y[i]), textcoords="offset points", xytext=(0,5), ha='center', fontsize=9) ax1.set_title('原始标签 - 严重重叠', fontsize=12) ax1.grid(True, linestyle='--', alpha=0.7) # 子图2:使用adjustText处理后的标签 ax2.scatter(x, y, color='red', zorder=5) # 先收集所有的文本对象 texts = [] for i, txt in enumerate(labels): texts.append(ax2.text(x[i], y[i], txt, fontsize=9)) # 调用adjust_text函数进行自动调整 adjust_text(texts, arrowprops=dict(arrowstyle='-', color='gray', lw=0.5), ax=ax2) ax2.set_title('经adjustText调整后', fontsize=12) ax2.grid(True, linestyle='--', alpha=0.7) plt.tight_layout() plt.show()

运行这段代码,你会看到左右两幅图的鲜明对比。左图完全无法辨认,右图的标签则被巧妙地分散开,并且用淡淡的灰色连线指向其原始数据点,清晰可辨。

核心代码解析

  1. 收集文本对象:与直接使用annotate不同,我们使用ax.text()创建文本对象,并将其存入一个列表textsax.text(x, y, s)是在坐标(x, y)处放置文本s的最基础方法。
  2. 调用adjust_text函数:这是唯一的关键步骤。将收集好的texts列表传给adjust_text函数。函数会原地修改这些文本对象的位置属性。
  3. arrowprops参数:这个参数非常有用。它告诉adjustText,在移动标签后,是否以及如何绘制一条从标签指向原始数据点的连线。arrowstyle='-'表示使用直线,lw=0.5是线宽。加上连线后,即使标签被移开,读者也能毫无歧义地知道它属于哪个点。

4. 核心参数详解与实战调优

adjust_text函数有二十多个参数,但常用的也就十来个。理解这些参数,你就能应对90%的场景。我们把它们分成几类来理解。

4.1 控制避让力度与精度

  • force_textforce_points:这是两个最重要的参数,分别控制标签之间、标签与点之间的排斥力强度。默认值通常在0.5-2之间。值越大,排斥力越强,标签会被推得更开,但可能离原始点更远。如果发现标签挤得不够开,可以适当增大这两个值(比如从1.5调到2.5)。如果图表空间充裕,可以调小一些让标签更靠近原点。

    adjust_text(texts, force_text=1.8, force_points=1.8) # 更强的排斥力
  • lim:迭代次数上限。默认500次通常足够。如果图表极其复杂,标签过多,可以增加到1000或1500。但迭代次数过多通常意味着参数(如力的大小)可能需要调整,或者问题本身过于复杂。

    adjust_text(texts, lim=800)
  • precision:判断两个对象是否“重叠”的精度。值越小(如0.001),判断越严格,计算越慢。默认值0.01对于屏幕显示和普通打印已经足够。除非你需要出版级的高精度定位,否则不需要修改。

4.2 控制移动方向与约束

  • expand_textexpand_points:这两个参数是列表,用于控制标签在x和y方向上的“可扩展性”。默认是(1,1),表示在两个方向上排斥力相等。如果你希望标签主要垂直方向移动以避免重叠,可以设置expand_text=(1, 2),这样y方向的力就是x方向的2倍。这在时间序列图中很常用,因为水平方向是时间轴,我们更希望标签在垂直方向上错开。

    # 倾向于在y轴方向移动标签 adjust_text(texts, expand_text=(1, 2), expand_points=(1, 2))
  • only_move:一个非常实用的参数,用于限制标签移动的类型。可选值有:

    • 'both'(默认):标签可以任意移动。
    • 'x':标签只能水平移动。
    • 'y':标签只能垂直移动。
    • 'xy':同'both'
    • 'exclude':一个实验性功能,可以排除某些点的影响。当你希望标签严格对齐在数据点的正上方或正下方时,设置only_move='y'会非常有效。

4.3 箭头与外观控制

  • arrowprops:如前所述,用于绘制指向线。它是一个字典,接受的参数与matplotlibFancyArrowPatch一致。除了样式,还可以控制连线是否弯曲(connectionstyle)。

    # 使用弯曲的连线,更美观 adjust_text(texts, arrowprops=dict(arrowstyle='-', color='gray', lw=0.7, connectionstyle='arc3,rad=0.2'))
  • autoalign:布尔值,默认为True。当标签被移动后,是否自动根据其相对于数据点的位置,调整文本的水平对齐方式(ha)。例如,如果标签被移到点的右边,对齐方式会自动从'center'变为'left'建议保持为True,这能让标签的指向更自然。

4.4 实战调优案例:处理城市经济数据散点图

假设我们有一个包含50个城市“人均GDP”与“科研投入占比”的散点图,需要标注所有城市名称。

import pandas as pd import matplotlib.pyplot as plt from adjustText import adjust_text # 模拟数据 np.random.seed(123) n_cities = 50 city_names = [f'City_{i:02d}' for i in range(n_cities)] gdp_per_capita = np.random.uniform(3, 12, n_cities) # 单位:万 rd_ratio = np.random.uniform(1.0, 4.5, n_cities) # 单位:% df = pd.DataFrame({ 'city': city_names, 'gdp': gdp_per_capita, 'rd': rd_ratio }) # 绘图 plt.figure(figsize=(12, 8)) plt.scatter(df['gdp'], df['rd'], alpha=0.7, edgecolors='k', s=80) # 1. 初始尝试:使用默认参数 texts_default = [] for _, row in df.iterrows(): texts_default.append(plt.text(row['gdp'], row['rd'], row['city'], fontsize=8)) # 先保存视图,避免adjust_text后坐标轴变化 ax = plt.gca() original_xlim = ax.get_xlim() original_ylim = ax.get_ylim() print("第一次调整(默认参数)...") adjust_text(texts_default, arrowprops=dict(arrowstyle='-', color='r', lw=0.3, alpha=0.5), ax=ax) ax.set_xlim(original_xlim) # 强制恢复原始坐标轴范围 ax.set_ylim(original_ylim) plt.title('默认参数调整结果', fontsize=14) plt.xlabel('人均GDP (万)') plt.ylabel('科研投入占比 (%)') plt.grid(True, alpha=0.3) plt.show() # 2. 优化尝试:针对密集区域加强避让,并限制垂直移动 plt.figure(figsize=(12, 8)) plt.scatter(df['gdp'], df['rd'], alpha=0.7, edgecolors='k', s=80) texts_optimized = [] for _, row in df.iterrows(): texts_optimized.append(plt.text(row['gdp'], row['rd'], row['city'], fontsize=8)) ax2 = plt.gca() print("第二次调整(优化参数)...") # 使用更强的排斥力,并倾向于垂直方向移动 adjust_text(texts_optimized, force_text=2.0, force_points=1.8, expand_text=(1, 1.5), # y方向移动权重更高 only_move='y', # 只允许垂直移动,保持x轴对齐 arrowprops=dict(arrowstyle='-', color='blue', lw=0.4, alpha=0.6), ax=ax2) ax2.set_xlim(original_xlim) ax2.set_ylim(original_ylim) plt.title('优化参数后 (force_text=2.0, only_move="y")', fontsize=14) plt.xlabel('人均GDP (万)') plt.ylabel('科研投入占比 (%)') plt.grid(True, alpha=0.3) plt.tight_layout() plt.show()

在这个案例中,第一次使用默认参数可能已经解决了大部分重叠,但某些密集区域可能仍不理想。第二次调整,我们通过增加force_text,并设置only_move='y',强制所有标签只在其数据点的垂直线上移动。这样得到的图,所有城市名都整齐地排列在对应数据点的正上方或正下方,虽然可能有些标签线拉得较长,但绝对杜绝了重叠,并且保持了x方向的对齐,便于读者横向比较。

5. 高级技巧与性能优化

当数据点达到数百甚至上千时,直接对所有点进行标注和调整可能会带来性能问题(计算时间长)和视觉混乱(图面过于拥挤)。此时需要一些策略。

5.1 选择性标注与分层处理

最有效的策略不是给所有点加标签,而是只标注关键点。adjustText可以与条件判断完美结合。

# 假设我们只标注“人均GDP”和“科研投入”都高于中位数的城市 median_gdp = df['gdp'].median() median_rd = df['rd'].median() key_cities = df[(df['gdp'] > median_gdp) & (df['rd'] > median_rd)] plt.figure(figsize=(10, 6)) # 绘制所有点,灰色,透明度高 plt.scatter(df['gdp'], df['rd'], color='lightgray', alpha=0.5, s=40, label='All Cities') # 高亮关键点 plt.scatter(key_cities['gdp'], key_cities['rd'], color='coral', edgecolors='k', s=100, label='Key Cities (High GDP & High R&D)') texts = [] for _, row in key_cities.iterrows(): texts.append(plt.text(row['gdp'], row['rd'], row['city'], fontsize=10, fontweight='bold')) adjust_text(texts, force_text=1.5, arrowprops=dict(arrowstyle='->', color='coral', lw=1, alpha=0.7)) plt.xlabel('人均GDP (万)') plt.ylabel('科研投入占比 (%)') plt.legend() plt.grid(True, alpha=0.3) plt.title('选择性标注关键城市', fontsize=14) plt.tight_layout() plt.show()

这种方法极大地减少了需要调整的标签数量,使图表重点突出,信息传达更有效。

5.2 处理超大点集的性能考量

如果你确实需要标注数百个点(例如在某种聚类结果可视化中),性能优化就很重要。

  1. 降低精度:将precision参数从默认的0.01提高到0.05或0.1,可以显著减少计算量,对于屏幕显示,细微的精度损失通常察觉不到。

    adjust_text(texts, precision=0.05, lim=1000)
  2. 分步调整:对于超密集区域,可以尝试先调整一部分,固定它们的位置,再调整另一部分。这可以通过多次调用adjust_text并指定不同的文本对象列表来实现,但逻辑较为复杂。

  3. 使用更快的算法(实验性)adjustText库内部有一个iterative参数(默认为True),使用的是力导向迭代。对于极端情况,可以尝试关闭它(iterative=False),使用一种基于“推挤”的简单算法,速度更快,但效果可能稍差。

    adjust_text(texts, iterative=False, force_text=0.5)

一个重要的经验是:在调用adjust_text之前,务必先调用plt.draw()或确保图形已经渲染。因为adjustText需要知道文本对象的精确像素边界框来进行碰撞检测。如果图形还没绘制,边界框是未知的,调整会失效。

fig, ax = plt.subplots() scatter = ax.scatter(x, y) texts = [ax.text(x[i], y[i], label) for i, label in enumerate(labels)] # 先绘制图形,确保文本边界框计算正确 plt.draw() # 这一行很重要! adjust_text(texts, ax=ax) plt.show()

6. 常见问题与排查技巧实录

在实际使用中,你肯定会遇到一些“诡异”的情况。下面是我踩过的一些坑和解决方法。

6.1 标签“飞走”或跑到图外

现象:调整后,有些标签跑到了坐标轴范围之外,甚至完全看不见了。原因:排斥力太强(force_textforce_points过大),或者迭代次数过多(lim太大),导致标签在迭代过程中被持续推离,最终“逃逸”。解决

  1. 设置边界约束:使用ensure_inside_axes参数(默认为False)。将其设为True,可以强制所有标签的边界框必须完全位于坐标轴区域内。
    adjust_text(texts, ensure_inside_axes=True)

    注意:这个参数有时会导致靠近边界的标签被过度挤压,产生新的重叠。需要权衡使用。

  2. 调整力的参数:适当降低force_textforce_points的值。
  3. 手动调整坐标轴范围:在调整后,如果只是少数标签轻微超出,可以稍微扩大坐标轴范围来容纳它们。
    adjust_text(texts) ax.relim() # 重新计算数据界限 ax.autoscale_view() # 自动缩放视图以包含所有艺术家(包括被移动的文本)

6.2 调整后仍有轻微重叠

现象:大部分标签分开了,但仍有少数几对贴得非常近,甚至还有像素级的重叠。原因:可能是precision设置得不够严格,或者迭代提前终止了(力太小或lim不够)。解决

  1. 增加迭代次数lim=8001000
  2. 提高排斥力:微调force_textforce_points,例如增加0.2。
  3. 降低精度阈值:设置precision=0.005或更小。注意这会增加计算时间。
  4. 局部手动微调:对于最终仍重叠的两三个标签,可以在adjust_text调整后,手动获取其位置并微调。虽然违背了自动化的初衷,但作为最后的手段是可行的。
    adjust_text(texts, ...) # 假设发现texts[5]和texts[12]还挨着 pos5 = texts[5].get_position() pos12 = texts[12].get_position() # 将第12个标签稍微向上移动一点 texts[12].set_position((pos12[0], pos12[1] + 0.02))

6.3 箭头连线混乱或指向不准

现象arrowprops绘制的连线交叉严重,或者没有指向数据点的中心。原因adjustText移动的是标签的文本框锚点(默认是文本框的左下角(0,0))。而箭头默认是从文本框的锚点指向数据点。如果标签的锚点没有随文本移动正确更新,或者文本对齐方式(ha,va)变化导致文本框整体偏移,箭头就会指歪。解决

  1. 依赖autoalign=True(默认):这个功能会自动根据标签相对于数据点的最终位置,调整文本的水平对齐方式,从而使箭头看起来是从标签“边缘”指向点,而不是从标签“内部”穿出,视觉效果更好。
  2. 自定义箭头连接点arrowprops可以接受patchApatchB参数来指定箭头连接的精确位置,但这涉及到更底层的matplotlib对象操作,比较复杂。对于绝大多数情况,保持autoalign=True并使用简单的直线箭头(arrowstyle='-')就能获得不错的效果。

6.4 与其他图形元素(如图例)重叠

现象:标签调整后,与手动添加的图例(plt.legend())或文本框重叠。原因adjustText只负责调整传入的texts列表中的对象之间的重叠,它不会考虑图例等其他图形元素。解决

  1. 后置图例:先调用adjust_text调整好所有数据标签,再调用plt.legend()添加图例。这样图例会盖在标签之上,可能遮挡标签。你可以通过调整图例的位置参数(loc)将其放在相对空旷的区域。
  2. 将图例视为一个“标签”参与调整(高级):可以将图例的边界框提取出来,当作一个特殊的“文本”对象加入texts列表一起调整。但这需要手动计算图例的位置和大小,实现起来比较麻烦,除非有极端精确的排版需求,否则不推荐。

6.5 排查流程速查表

当你遇到调整效果不理想时,可以按以下步骤排查:

问题现象可能原因解决步骤
标签完全没动1. 忘记调用plt.draw()
2.force_textforce_points为0
1. 在adjust_text前加plt.draw()
2. 检查参数,确保力参数大于0
标签乱飞,出图排斥力过大,或无限迭代1. 降低force_text/points(如从2.0降到1.2)
2. 设置ensure_inside_axes=True
3. 检查lim是否过大
中心区域仍重叠排斥力不足,或迭代不够1. 增加force_text/points(如从1.0加到1.8)
2. 增加lim(如从500加到800)
3. 尝试设置expand_text=(1, 2)加强垂直避让
调整速度极慢标签数量过多(>500)1. 考虑选择性标注
2. 降低precision(如设为0.05)
3. 尝试iterative=False
箭头很难看autoalign可能未生效或箭头样式问题1. 确保autoalign=True
2. 尝试更简洁的arrowstyle='-'
3. 调低箭头透明度alpha

最后,分享一个我个人的小技巧:在进行最终出图前,将调整过程可视化出来。你可以设置一个较小的lim(比如50),然后观察标签是如何一步步被推开的。这能帮你直观地理解参数的作用,更快地找到合适的配置。虽然adjustText是一个自动化工具,但理解和驾驭它,才能让它真正成为你可视化工具箱中的得力助手。

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

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

立即咨询