
1. 问题场景当散点图的标签变成“一锅粥”做数据可视化尤其是用 Python 的 Matplotlib 画散点图时我们经常会遇到一个非常具体且恼人的问题给每个数据点加上文本标签后整个图面就乱成了一团。标签之间互相重叠、挤压、覆盖别说获取信息了连看清数据点本身都变得困难。这种情况在什么场景下最常见我总结了几类高密度数据点比如展示成百上千个城市的人口与GDP关系每个城市都需要标注名称。分类对比图在同一个坐标系中比较多个类别如不同品牌、不同算法模型的指标每个类别有多个样本点需要标注类别名或样本ID。学术图表在论文或报告中需要清晰标注出某些关键数据点如异常点、拐点、特定实验组的具体信息。你可能会想Matplotlib 不是自带plt.text()或ax.annotate()吗手动调一下位置不就行了对于几个、十几个点这确实可行。但一旦数据量上来手动调整就成了一场噩梦。每个标签的xytext偏移量都得反复尝试牵一发而动全身调好一个另一个又乱了。更糟糕的是当图表需要批量生成或者数据动态变化时手动调整完全不可行。这就是为什么我们需要一个智能的、自动化的解决方案。而adjustText库正是为了解决“标签布局”这个细分痛点而生的利器。它不是简单地画图而是专门优化文本在图表中的空间排列其核心目标只有一个在避免重叠的前提下让所有标签清晰可读。接下来的内容我将带你彻底搞懂如何用adjustText解决这个顽疾。我们会从最基础的安装和原理讲起通过一个完整的、可复现的案例一步步演示如何从“灾难性”的重叠图表优化成一张专业、清晰的成品。我还会分享我在实际使用中踩过的坑和总结出的调参经验这些在官方文档里可不一定找得到。2. adjustText库它是什么以及如何工作的在深入代码之前我们有必要先了解一下adjustText这个工具。它不是一个绘图库而是一个基于 Matplotlib 的“后处理”工具。你可以把它想象成一个拥有审美强迫症的图表排版助手。2.1 核心思想迭代调整与力导向模型adjustText的工作原理非常直观模拟了一个物理过程初始化你通过plt.text()或ax.annotate()把所有的文本标签放在它们初始的位置通常是数据点的坐标。检测冲突算法会计算所有文本标签被视为一个个矩形框之间以及标签与数据点之间的重叠情况。施加“力”如果检测到重叠算法会在重叠的物体之间模拟一种“排斥力”。同时每个标签与其对应的原始数据点之间还存在一种“引力”防止标签飞得太远。迭代调整算法在一个循环中根据这些“力”轻微地移动每一个标签的位置。经过多次迭代比如几百次整个系统会逐渐趋于一个平衡状态——标签之间不再重叠并且都尽可能地靠近自己对应的数据点。这个过程被称为“力导向算法”或“迭代调整”。adjustText封装了这个复杂的算法我们只需要调用一个简单的函数adjust_text()它就会自动完成上述所有步骤。2.2 与Matplotlib原生功能的对比Matplotlib 本身提供了一些基础的文本布局控制比如rotation: 旋转文本。ha/va(horizontal/vertical alignment): 文本的水平/垂直对齐方式。bbox: 给文本加个背景框。但这些功能都是被动的、局部的。它们能改变单个标签的样式但无法从全局视角智能地解决多个标签之间的空间竞争问题。adjustText做的正是这个全局优化。2.3 安装与导入安装非常简单通过 pip 即可完成pip install adjustText在你的 Python 脚本中通常这样导入import matplotlib.pyplot as plt from adjustText import adjust_text # 当然通常还会用到 numpy 和 pandas 来处理数据 import numpy as np import pandas as pd现在理论基础已经打好让我们进入实战环节。我将构造一个典型的高密度标签场景看看不用adjustText时有多糟糕然后再展示如何用它化腐朽为神奇。3. 实战从标签重叠的灾难到清晰的可视化我们用一个模拟的场景来演示假设我们分析了50款手机横轴代表性能跑分纵轴代表价格我们需要在散点图上标出每一款手机的名称。3.1 创建模拟数据与“灾难性”初始图表首先我们生成一些随机数据来模拟这50款手机。# 生成模拟数据 np.random.seed(42) # 固定随机种子确保结果可复现 n_points 50 # 性能分0-100 performance np.random.uniform(20, 95, n_points) # 价格1000-8000元 price np.random.uniform(1000, 8000, n_points) # 生成手机型号名如 “Model_01” labels [fModel_{i1:02d} for i in range(n_points)] # 创建图表 fig, ax plt.subplots(figsize(12, 8)) scatter ax.scatter(performance, price, alpha0.6, edgecolorsw, s80) # 初始尝试直接为每个点添加文本标签灾难的开始 texts [] for i, (x, y, label) in enumerate(zip(performance, price, labels)): # 将标签放在数据点右上角一点点 text ax.text(x, y, label, fontsize9, haleft, vabottom) texts.append(text) ax.set_xlabel(Performance Score, fontsize12) ax.set_ylabel(Price (CNY), fontsize12) ax.set_title(Mobile Phone Analysis: Performance vs. Price (With Overlapping Labels), fontsize14) ax.grid(True, linestyle--, alpha0.5) plt.tight_layout() plt.show()运行这段代码你会得到一张典型的“标签灾难图”。密密麻麻的文字堆叠在一起根本无法阅读数据点也被完全遮盖。这张图如果放在报告里绝对是反面教材。注意这里我特意使用了haleft, vabottom并将文本坐标设为(x, y)这会让文本的左上角对准数据点是造成严重重叠的常用错误方式之一。即使你换成hacenter, vacenter让文本居中于数据点在密度高的情况下重叠问题依然存在。3.2 引入adjustText进行自动调整现在我们请出主角adjustText。修改上面的代码在创建所有text对象之后、调用plt.show()之前添加一行魔法代码。# ... 前面的数据生成和绘图代码不变 ... texts [] for i, (x, y, label) in enumerate(zip(performance, price, labels)): text ax.text(x, y, label, fontsize9, haleft, vabottom) texts.append(text) # 关键的一行自动调整文本位置避免重叠 adjust_text(texts, arrowpropsdict(arrowstyle-, colorgray, lw0.5)) ax.set_xlabel(Performance Score, fontsize12) ax.set_ylabel(Price (CNY), fontsize12) ax.set_title(Mobile Phone Analysis: Performance vs. Price (Labels Adjusted by adjustText), fontsize14) ax.grid(True, linestyle--, alpha0.5) plt.tight_layout() plt.show()再次运行你会看到截然不同的结果。所有的标签都被巧妙地挪开了它们之间保持了清晰的间隙。adjustText自动为那些移动距离较远的标签添加了灰色的连接线由arrowprops参数控制指向其原始的数据点确保了标签和数据之间的对应关系不被误解。对比这两张图效果立竿见影。第一张是无效的信息堆砌第二张则成为了真正能传递信息的可视化作品。adjustText的核心价值就在这里它把我们从繁琐低级的手动排版劳动中解放出来去关注更重要的数据分析和故事讲述。4. 核心参数详解如何精细控制调整行为adjust_text()函数有很多参数理解它们才能应对各种复杂场景。下面我结合自己的使用经验详细解释几个最关键、最常用的参数。4.1 调整力度与精度lim,precision,force_text和force_points这几个参数共同控制了算法调整的“强度”和“决心”。lim (int, default500):迭代次数限制。这是算法运行的最大迭代步数。通常不需要修改除非图表极其复杂比如上千个标签500次迭代足以让系统稳定。如果看到标签还在轻微抖动可以适当增加到800或1000。precision (float, default0.01):移动精度。在每次迭代中标签移动的最小步长。值越小调整越精细但可能需要更多迭代才能达到平衡。除非有特殊需求否则使用默认值即可。force_text (float, default0.1):文本间排斥力系数。这个值越大标签之间的“排斥力”就越强它们会更努力地分开。如果你的标签非常密集可以尝试稍微调大这个值例如0.15或0.2。force_points (float, default0.2):文本与点之间的引力系数。这个值越大标签就越倾向于呆在它原始的数据点附近。如果你不希望标签跑得太远可以增大这个值。反之如果图表空间拥挤允许标签远离点以换取不重叠可以适当减小这个值比如0.1。我的经验大多数情况下默认参数就能工作得很好。我首先调整的是force_text和force_points。如果标签挤在一起分不开就增大force_text如果标签被弹飞得太远看着不美观就增大force_points。这是一个微妙的平衡。4.2 箭头与连接线arrowprops和add_arrows当标签被移动后为了不误导读者用一根线把它和原始数据点连接起来是非常好的实践。add_arrows (bool, defaultFalse): 是否添加连接线。我强烈建议始终将其设为True或者通过arrowprops参数来隐式启用。arrowprops (dict, defaultNone): 这是一个字典会直接传递给 Matplotlib 的FancyArrowPatch来绘制箭头。通过它你可以全面控制连接线的样式。# 推荐的箭头样式配置 arrowprops_dict dict( arrowstyle-, # 线型 ‘-’ 实线 ‘--’ 虚线 ‘-.’ 点划线 colorgray, # 线的颜色 lw0.8, # 线宽 alpha0.6, # 透明度 connectionstylearc3,rad0.1 # 连接样式 rad控制弯曲度 ) adjust_text(texts, arrowpropsarrowprops_dict)提示将连接线设置为浅灰色 (colorgray)、细线 (lw0.8)、半透明 (alpha0.6)可以让它起到引导作用的同时又不至于喧宾夺主干扰对数据点和标签本身的观察。4.3 避免与谁重叠avoid_self和avoid_pointsadjustText默认会避免标签与所有其他对象重叠包括其他标签、数据点甚至它自己对应的数据点。你可以通过这两个参数进行更精细的控制。avoid_self (bool, defaultTrue): 是否避免标签与其自身对应的数据点重叠。通常保持为True。如果你故意想让标签盖住点比如点非常小可以设为False。avoid_points (bool, defaultTrue): 是否避免标签与所有数据点重叠。这也是一个很好的默认设置。但在一些特殊图表中你可能只关心标签之间不重叠而不介意标签盖住其他不相干的点这时可以设为False。4.4 扩展边界与额外对象expand和avoid_objectsexpand (tuple or list, default(1.2, 1.2)):膨胀因子。这是一个非常实用的参数。算法在计算标签的边界框用于碰撞检测时会将其长宽分别乘以expand给出的因子。(1.2, 1.2)意味着边界框被放大了20%。这相当于在标签周围强制保留了一定的“安全边距”让最终排版看起来更宽松、更舒服。如果你觉得标签排得还是太紧可以尝试增大到(1.3, 1.3)或(1.5, 1.5)。avoid_objects (list, defaultNone): 这是一个高级功能。你可以传入一个 Matplotlib 对象如Patch,Collection的列表adjustText在调整时会额外避免与这些对象重叠。例如如果你在图上画了几个重要的矩形区域不希望标签侵入就可以把它们加到这个列表里。掌握了这些核心参数你就能应对90%的标签调整场景。下面我们来看一个更复杂、更贴近真实需求的案例。5. 高级应用与性能优化真实世界的数据可视化往往更复杂。我们可能有多组数据、需要不同的颜色和标记或者数据量巨大。adjustText在这些场景下依然能胜任但需要一些技巧。5.1 处理分组数据与图例假设我们现在要比较三个不同品牌Brand A, B, C的手机。我们需要用不同颜色区分它们并且可能还需要图例。# 生成分组数据 np.random.seed(123) brands [Brand_A, Brand_B, Brand_C] colors [#FF6B6B, #4ECDC4, #556270] n_per_brand 20 fig, ax plt.subplots(figsize(14, 9)) all_texts [] scatter_handles [] # 用于图例 for idx, brand in enumerate(brands): # 为每个品牌生成数据 perf np.random.normal(loc50idx*15, scale10, sizen_per_brand) price np.random.normal(loc4000-idx*800, scale1500, sizen_per_brand) labels [f{brand}_{i} for i in range(n_per_brand)] # 绘制散点 sc ax.scatter(perf, price, ccolors[idx], alpha0.7, s100, edgecolorsw, labelbrand) scatter_handles.append(sc) # 为每个点添加标签 for x, y, lbl in zip(perf, price, labels): # 注意这里故意使用小字体和紧凑对齐制造重叠 txt ax.text(x, y, lbl, fontsize7, colordarkgray, hacenter, vacenter) all_texts.append(txt) # 应用adjustText进行全局调整 adjust_text(all_texts, arrowpropsdict(arrowstyle-, colorlightgray, lw0.5, alpha0.7, relpos(0,0)), force_text0.15, # 稍微加大排斥力 expand(1.2, 1.2)) # 添加图例 ax.legend(handlesscatter_handles, titleBrand, fontsize11, title_fontsize12) ax.set_xlabel(Performance Score, fontsize13) ax.set_ylabel(Price (CNY), fontsize13) ax.set_title(Multi-Brand Phone Analysis with Adjusted Labels, fontsize15, pad15) ax.grid(True, linestyle--, alpha0.4) plt.tight_layout() plt.show()在这个例子中关键点在于统一处理我们将三个品牌的所有标签对象都收集到all_texts这个列表中然后一次性交给adjust_text()处理。这让算法能在全局范围内进行最优布局不同品牌的标签也会互相避让。样式区分虽然标签文本颜色我统一用了darkgray但你可以通过更复杂的逻辑让标签颜色跟随散点颜色增强关联性。图例共存adjustText只处理传入的Text对象列表不会影响其他的图表元素如散点、图例、轴线。它们可以完美共存。5.2 应对超大数据量性能考量与简化策略adjustText的算法复杂度与标签数量的平方相关。当标签数量超过几百个时调整速度会明显变慢甚至可能因为迭代次数不足而得不到理想效果。对于超大数据量比如 500 个标签我有以下实战建议策略一抽样标注而非全量标注这是最重要的原则。在一张图上显示成千上万个标签本身就是不合理的会带来认知负荷。可视化应该突出关键信息。只标注那些最重要的点例如离群点Outliers最大值、最小值点特定阈值以上的点聚类后的簇中心点策略二先筛选后调整在生成标签列表texts时就只为你关心的数据点创建Text对象。# 假设df是一个包含‘performance’, ‘price’, ‘name’, ‘is_important’列的DataFrame texts [] for idx, row in df.iterrows(): if row[is_important]: # 只标注重要的点 txt ax.text(row[performance], row[price], row[name], fontsize8) texts.append(txt) adjust_text(texts, ...)策略三调整算法参数以提升速度如果确实需要标注大量点可以尝试调整参数来加速lim: 减少迭代次数如从500降到200。结果可能不是最优但能快速得到一个可接受的版本。precision: 增加移动步长如从0.01升到0.05。调整会变粗糙但收敛更快。关闭箭头add_arrowsFalse或arrowprops设为None能节省一部分计算和渲染开销。策略四分而治之如果数据在空间上天然分成了几个簇可以考虑对每个簇分别调用adjust_text()。但这需要额外的聚类算法支持实现起来较复杂属于高级用法。我的经验在99%的业务场景中策略一选择性标注是最佳实践。它既保证了图表的清晰度又提升了性能还引导观众关注核心信息。6. 避坑指南与最佳实践在使用adjustText的几年里我积累了一些官方文档没写的“血泪教训”。这里分享给你希望能帮你少走弯路。6.1 常见问题与排查问题1调整后标签“飞”得太远连接线过长图表显得很乱。原因force_points引力太小而force_text斥力太大或初始重叠太严重。解决首要方法是增大force_points例如从0.2增加到0.5甚至0.8把标签“拉”回数据点附近。其次可以尝试增大expand例如从(1.2,1.2)到(1.5,1.5)给标签更多初始空间减少排斥的需要。检查标签初始位置。使用hacenter, vacenter让标签居中于数据点通常比haleft, vabottom的初始重叠更少。问题2调整似乎没起作用标签依然大面积重叠。原因lim迭代次数可能不够系统尚未收敛。force_text排斥力可能太小。图表画布figsize太小物理空间不足。解决增加lim值如lim800。逐步增加force_text如force_text0.2。最有效的方法增大图形尺寸。在plt.subplots()中设置更大的figsize如figsize(16, 10)。这给了标签移动的“战场”。考虑是否真的需要标注每一个点应用“选择性标注”原则。问题3调整过程非常慢。原因标签数量过多N300。解决参考上一节“应对超大数据量”的策略尤其是减少标签数量。这是根本解决方法。6.2 最佳实践工作流根据我的经验一个稳健的使用adjustText的工作流如下数据与绘图先完成你的散点图绘制确定好坐标轴、颜色、标记等所有视觉元素。创建标签对象使用循环创建所有的ax.text()对象并将它们存入一个列表如texts。在创建时给标签一个合理的初始位置和对齐方式例如hacenter, vacenter。首次调整默认参数先只用最基本的adjust_text(texts)或adjust_text(texts, arrowprops...)运行一次观察效果。迭代调参如果效果不理想按以下顺序调整参数图表空间首先考虑调整figsize提供更大画布。引力与斥力微调force_points和force_text。标签太远就加大force_points标签太挤就加大force_text。安全边距调整expand参数让排版更宽松。箭头样式最后调整arrowprops让连接线更美观或不显眼。最终检查仔细查看调整后的图表确保没有标签被意外地推到坐标轴范围之外adjustText默认会避免但极端情况下可能发生。如果有你可能需要手动微调一两个标签或者稍微扩大坐标轴范围ax.set_xlim()。6.3 一个综合示例调参前后的对比让我们用一个例子来展示调参如何改变结果。我们将创建一组数据并对比三张图原始重叠标签。使用adjustText默认参数。经过调优参数后的效果。# 生成更密集的测试数据 np.random.seed(2024) x np.random.rand(30) * 100 y np.random.rand(30) * 100 names [fP{i} for i in range(30)] fig, axes plt.subplots(1, 3, figsize(24, 6)) # 图1原始重叠标签 ax1 axes[0] ax1.scatter(x, y, s80, alpha0.6) texts1 [] for xi, yi, name in zip(x, y, names): t ax1.text(xi, yi, name, fontsize10, hacenter, vacenter) texts1.append(t) ax1.set_title(1. Original Overlapping Labels, fontsize14) ax1.grid(True, alpha0.3) # 图2默认参数调整 ax2 axes[1] ax2.scatter(x, y, s80, alpha0.6) texts2 [] for xi, yi, name in zip(x, y, names): t ax2.text(xi, yi, name, fontsize10, hacenter, vacenter) texts2.append(t) # 使用默认参数只添加箭头 adjust_text(texts2, arrowpropsdict(arrowstyle-, colorr, lw0.8, alpha0.6), axax2) ax2.set_title(2. Adjusted (Default Params), fontsize14) ax2.grid(True, alpha0.3) # 图3调优参数后 ax3 axes[2] ax3.scatter(x, y, s80, alpha0.6) texts3 [] for xi, yi, name in zip(x, y, names): t ax3.text(xi, yi, name, fontsize10, hacenter, vacenter) texts3.append(t) # 调优参数加大引力增加安全边距使用更不显眼的连接线 adjust_text(texts3, arrowpropsdict(arrowstyle-, colorgray, lw0.5, alpha0.4), force_points0.5, # 增大引力让标签更靠近点 expand(1.3, 1.3), # 增大安全边距 axax3) ax3.set_title(3. Adjusted (Tuned Params: force_points0.5, expand(1.3,1.3)), fontsize14) ax3.grid(True, alpha0.3) plt.tight_layout() plt.show()通过这个对比你可以清晰地看到图1是混乱的起点。图2使用默认参数后标签被分开但有些标签离点较远红色连接线也比较醒目。图3经过调参force_points0.5,expand(1.3,1.3)标签在避免重叠的同时更紧密地聚集在数据点周围灰色的细连接线也更为低调。整体视觉效果更加平衡和专业。这个过程就是应用adjustText的精髓它不是一键魔法而是一个需要你根据具体图表进行微调的工具。理解每个参数背后的含义才能让它发挥出最大的效用。