资讯动态

Matplotlib断轴绘图实战:解决数据量级差异的可视化技巧

发布时间:2026/10/16 1:17:21 来源:尧图企业网站定制
1. 断轴解决的到底是什么问题1.1 数据里的“小波动被大差异吃掉”现象先说场景。我接触断轴这个需求最早是在一批传感器数据上。大部分时间里信号都稳定在 0 到 50 这个区间曲线细节很丰富但中间偶尔会出现几个尖峰数值直接冲到 600 上下。如果用同一个坐标轴从头画到尾你会发现整张图几乎只有一条贴着 x 轴的平线几个尖峰看起来像几条极细的竖线而 0 到 50 区间的真实波动全部被压成了一条没有厚度的直线。这就是典型的“小波动被大差异吃掉”问题。断轴broken axis就是专门对付这种数据量级差距过大的场景把纵轴或者横轴拆成两段分别用不同的坐标范围去展示不同区间并在中间放一个视觉断口提醒看图的人坐标并不是连续的。这种问题在实验测量、设备监控、地震波分析、光谱曲线等很多领域都会遇到。数据本身没问题问题出在你给图分配的空间有限。如果为了照顾离群值把纵轴量程设得很大主要数据区间的分辨率就牺牲掉了如果为了看清主要区间而把量程缩小离群值又会跑出画布。断轴的本质就是“用两个独立坐标轴分别放大两个区间”然后把它们拼接在同一张图上。它并不是一个花哨的装饰而是一个实用性很强的信息压缩手段。1.2 断轴、双 y 轴、对数轴怎么选很多人第一次遇到量级差异大的数据会先想是不是改用对数坐标更合适。这个思路没错但要注意区分场景。对数轴适合“数值跨度达到几个数量级”的情况比如从 10 到 100000 这种因为对数能把乘除关系变成加减关系趋势比较均匀但如果你只想突出 0 到 50 内的微小波动而对数坐标会把这个区间的变化也压得很平反而看不出来。双 y 轴适合两组不同量纲的指标放在一起对比比如温度和流量两个轴可以各有各的刻度但它并不解决“同一组数据里有离群值”的问题。断轴则适合“同一条曲线内部存在明显分层”的数据目标是保住每一段的细节。我用下面这个简单对比帮你记忆对数轴压缩的是“数值尺度”双 y 轴解决的是“量纲冲突”断轴解决的是“量程浪费”。断轴的代价也很明显它把坐标轴的不连续性暴露出来所以看图的人必须清楚中间断掉的那一段没有任何数据否则容易把两段曲线误判成跳跃。这也是为什么断口标记一定要画得足够明显后面我会专门讲怎么把它画对。2. 断轴的核心原理两个 Axes 拼一台戏2.1 为什么一个 Axes 做不到“不连续坐标”在用 matplotlib 做断轴之前必须先搞清楚一个底层问题一个 Axes 对象内部的坐标映射是连续且线性的。也就是说一旦你设定 y 轴范围是 0 到 600那么 y100 和 y200 之间的距离就一定等于 y500 和 y600 之间的距离它们共用同一个线性比例。你没有办法在同一个 Axes 里告诉 matplotlib“前 50 个单位的像素宽度和后面 50 个单位的像素宽度不一样”因为它内部的数据坐标到屏幕坐标映射就是一条直线。有人会想那我直接把 y 轴刻度改成 0 到 50 然后跳到 550 到 600行不行不行。刻度标签可以造假但数据点、网格线、坐标轴的跨度依然按照 0 到 600 去渲染所谓的“断轴”只是标签上的断视觉上曲线还是占据整个轴仍然会把小波动压扁。所以唯一可靠的做法是创建两个独立的 Axes一个负责展示低区间一个负责展示高区间然后让它们在画布上紧挨着摆放并想办法让两个 Axes 看起来像一幅图的上下两部分。这就是断轴实现的核心思路。2.2 坐标变换与 Spine 上戏要真正看懂断轴的代码需要理解两个概念坐标变换transform和 Spines。matplotlib 绘图的坐标变换是有层级的。数据从数值到屏幕会先经过数据坐标映射到 Axes 坐标再经过 Axes 坐标映射到 Figure 坐标最后变成显示器像素。断轴里用得最多的是Axes.transAxes变换。在这个变换下一个 Axes 的左下角永远是(0, 0)右上角永远是(1, 1)与数据值无关。也就是说不管你的 y 轴范围是 0 到 50 还是 550 到 600在transAxes坐标系里这个 Axes 的高度始终是 1 个单位。画断口标记时我们恰恰需要这样的“与数据无关”的坐标因为它要画在 Axes 的边框附近而不是跟随数据偏移。Spines 就是 Axes 的四个边框。在 matplotlib 里每个 Axes 默认有top、bottom、left、right四条 spine它们合起来构成一个矩形框。断轴的视觉拼接本质上就是对两个 Axes 的 spine 做“选择性隐藏”上方的 Axes 隐藏底边下方的 Axes 隐藏顶边中间留出一个小间隙这样两个 Axes 看起来就是同一个大轴中间被切掉了一段。如果你不理解 spine 的隐藏逻辑画出来的图大概率会变成“两个独立小图叠在一起”断口效果非常假。2.3 断口标记为什么必须用 Axes 坐标隐藏了中间两条 spine 之后两个 Axes 之间会留下一条空白缝隙。如果不加任何标记看图的人会以为这是两个独立的子图或者以为数据漏了一段。所以要在缝隙上下两侧画上经典的斜线锯齿标记。画这个标记时如果还用数据坐标就会很麻烦因为两个 Axes 的数据范围不同同一个斜线在上下两个 Axes 里对应的坐标位置完全不一样你必须分别计算。而使用transAxes坐标后问题立刻简化在每个 Axes 里缝隙那条边都对应着坐标y0或y1斜线只需要画在对应边的 0 到某个比例之间即可。这就是断口标记“必须用 Axes 坐标”的原因。它要表达的是“这条边被人为切断了”而不是表达任何数据含义。用数据坐标去画既容易因为 y 轴范围改变而失效也容易画到离数据太远的地方。后面代码部分我会具体演示这些坐标值怎么填。3. 断轴代码逐行拆解与复现3.1 先造一份“两极分化”的数据为了演示我先构造一组包含离群值的模拟数据。这里的重点不是数据有多真实而是让它和标题匹配大部分点都落在一个低区间少量点落在高区间。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) x np.linspace(0, 10, 200) y np.where(x 8, np.random.normal(5, 0.8, 200), np.random.normal(600, 12, 200))先看前 80% 的点集中在 5 附近后 20% 的点在 600 附近。如果直接plt.plot(x, y)并让 y 轴自动缩放前面那段曲线保证会被压扁。为了让数据更平滑也可以直接设计成一段正弦波加一段高值脉冲但分布原理都一样。我这里故意用随机数是为了保证数据有“真实感”也方便你自己换数据测试。如果你没有现成数据也可以先不用完整数据直接手动造两个区间比如低区间用np.random.randn(100)生成高区间叠加 600 的偏移。关键是要让两段之间出现一个巨大的空档这样断轴才有意义。实际操作中你只需要确定低区间的上限和高区间的下限比如 50 和 550然后让两个子图的 y 轴范围分别覆盖这两个区间。3.2 搭建共享轴双子图断轴的第一步是创建两个上下排列的子图并让它们共享 x 轴。fig, (ax1, ax2) plt.subplots(2, 1, sharexTrue) fig.subplots_adjust(hspace0.05)这里sharexTrue有两个作用一是让上下两个子图使用相同的 x 轴范围和刻度位置保证数据在横向上对齐二是在默认情况下上方的 Axes 不会显示 x 轴刻度标签只有下方的 Axes 会显示这正好符合断轴的需求。如果你不共享 x 轴而是手动设置两个子图的xlim那么在缩放或平移时很容易出现两个子图不同步的情况所以我建议静态图也尽量用共享轴。fig.subplots_adjust(hspace0.05)是调节两个子图之间的垂直间隙这个 0.05 代表间隙高度为平均子图高度的 5%。默认值通常太大会导致两个 Axes 中间隔开明显的一块断口看起来不像切断反而像两个独立面板。调整到 0.05 左右两个 Axes 就会贴得很近中间只留一条细缝。这条细缝就是后续断口标记发挥作用的舞台。3.3 隐藏边框、清理刻度接下来分别给两个 Axes 绘图并设置各自的 y 轴范围。上面的 Axes 显示低区间下面的 Axes 显示高区间。ax1.plot(x, y) ax1.set_ylim(0, 50) ax1.spines.bottom.set_visible(False) ax1.tick_params(bottomFalse) ax2.plot(x, y) ax2.set_ylim(550, 650) ax2.spines.top.set_visible(False) ax2.tick_params(topFalse)这段代码有两个容易漏掉的细节。第一个是ax1.spines.bottom.set_visible(False)它把上子图的下边框藏起来对应的ax2.spines.top.set_visible(False)把下子图的上边框藏起来。两条边框一藏两个 Axes 之间的矩形边界感就消失了。第二个是tick_params(bottomFalse)和tick_params(topFalse)它们用来隐藏接近断口处的刻度线。如果不隐藏这些刻度线断口处会冒出一些短的刻度标记视觉上非常杂乱。注意这里隐藏的是 tick 本身而不是 tick 标签sharexTrue已经处理了大致的标签问题但 tick 线还需要你手工清理。如果你的数据范围和我不同把set_ylim改成你自己的两段范围即可。关键是两段范围之间一定要留出足够大的空档也就是低区间上限和高区间下限不能接上否则断口就失去意义。3.4 用 transAxes 画断口标记边框隐藏完成后中间那条细缝还需要斜线标记。这一步是断轴代码最不好理解的部分我逐行说明。d 0.5 kwargs dict(marker[-1, 1], markersize12, linestylenone, colork, meck, mew1, clip_onFalse) ax1.plot([0, 1], [0, d], transformax1.transAxes, **kwargs) ax1.plot([1, 0], [0, d], transformax1.transAxes, **kwargs) ax2.plot([0, 1], [1 - d, 1], transformax2.transAxes, **kwargs) ax2.plot([1, 0], [1 - d, 1], transformax2.transAxes, **kwargs)先说d 0.5。它表示斜线在 Axes 高度方向上占的比例也就是斜线跨度是半轴高度。如果你的断口间隙比较小可以适当减小 d比如 0.3如果断口间隙较大可以加大到 0.6。这个参数不是固定的你需要根据图的实际尺寸微调。linestylenone表示不画连续的连接线只画 marker。marker[-1, 1]是自定义 marker 路径最终会在端点位置画出一小段斜线方向从左上到右下。clip_onFalse很关键它告诉 matplotlib 不要裁剪超出 Axes 区域的 marker否则画在边缘附近的内容可能被切掉。以ax1.plot([0, 1], [0, d])为例在transAxes坐标系下起点是(0, 0)也就是上子图的左下角终点是(1, 0.5)也就是上子图底部边界的中间偏上方。这两点之间会画出 marker 斜线。ax1.plot([1, 0], [0, d])则从右下角画到右上角方向两条 marker 组合起来正好在断口下缘形成一个类似于 X 的锯齿。下面ax2的两条线作用相同只是坐标换到断口上缘y 从1 - d到1也就是从中间偏下到上边缘。这里经常有人问为什么不能直接画ax1.plot([0, 1], [0, 0], ...)因为 y 等于 0 时marker 会完全贴在下边框上断口缝隙里看不到明显标记用d0.5能让斜线伸进 Axes 内部一点视觉上更有立体感。调好 d 之后断口的锯齿效果才会明显。3.5 完整可复制的示例代码把上面的零散代码拼起来就是一份可以直接运行的断轴图脚本。import numpy as np import matplotlib.pyplot as plt np.random.seed(42) x np.linspace(0, 10, 200) y np.where(x 8, np.random.normal(5, 0.8, 200), np.random.normal(600, 12, 200)) fig, (ax1, ax2) plt.subplots(2, 1, sharexTrue) fig.subplots_adjust(hspace0.05) ax1.plot(x, y, colortab:blue) ax1.set_ylim(0, 50) ax1.spines.bottom.set_visible(False) ax1.tick_params(bottomFalse) ax2.plot(x, y, colortab:blue) ax2.set_ylim(550, 650) ax2.spines.top.set_visible(False) ax2.tick_params(topFalse) d 0.5 kwargs dict(marker[-1, 1], markersize12, linestylenone, colork, meck, mew1, clip_onFalse) ax1.plot([0, 1], [0, d], transformax1.transAxes, **kwargs) ax1.plot([1, 0], [0, d], transformax1.transAxes, **kwargs) ax2.plot([0, 1], [1 - d, 1], transformax2.transAxes, **kwargs) ax2.plot([1, 0], [1 - d, 1], transformax2.transAxes, **kwargs) plt.show()运行之后你会看到上半部分显示 0 到 50 的细节下半部分显示 550 到 650 的细节中间隔着锯齿断口。这个脚本就是所有断轴变体的基础。4. 进阶定制断口大小、水平断轴和多段断开4.1 hspace 与断口大小怎么定量控制很多人调完hspace0.05后中断处的空白大小还是不合适。这里有一个容易被忽略的点hspace的单位是“平均子图高度”的百分比而不是像素。所以当整个 Figure 的高度比较大时0.05 产生的实际空白也会相应变大。如果你希望断口更紧凑可以把hspace改成 0.02 甚至 0.01。但要注意间隙太小的话上下两个 Axes 会非常贴近锯齿 marker 可能会被上下两边的曲线盖住看起来不够清楚。间隙太大的话两个子图又会显得像两个独立图表断轴的“拼接感”变弱。我的经验是在图片导出尺寸大约是 10 寸宽、6 寸高时hspace0.05是一个比较稳妥的起点如果你觉得锯齿太挤就调大一点比如 0.08如果你觉得空隙太宽就调小到 0.03。另外不要只用肉眼看。先确定上下两个ylim的跨度再决定断口空白大概要占图片总高度的多少。比如低区间 0 到 50跨度 50高区间 550 到 650跨度 100那么你希望中间空档大约占整体高度的 5% 到 10% 比较正常。你可以通过fig.get_size_inches()获取当前 figure 尺寸再换算一下 hspace 对应的物理距离这样调整起来就比较有数了。4.2 水平断轴的改造方案断轴不仅能断纵轴也能断横轴。水平断轴适合 x 轴存在大间隙的场景比如把实验时间分成两段中间有一个很长的停机期不显示。实现逻辑和纵向断轴完全对称把两个子图改成左右排列把共享轴从sharex改成sharey然后隐藏左右两边对应的 spine。我给出一个常见的水平断轴骨架fig, (ax1, ax2) plt.subplots(1, 2, shareyTrue) fig.subplots_adjust(wspace0.05) ax1.plot(x1, y) ax1.set_xlim(0, 1) ax1.spines.right.set_visible(False) ax1.tick_params(rightFalse) ax2.plot(x2, y) ax2.set_xlim(5, 6) ax2.spines.left.set_visible(False) ax2.tick_params(leftFalse) d 0.5 kwargs dict(marker[-1, 1], markersize12, linestylenone, colork, meck, mew1, clip_onFalse) ax1.plot([1 - d, 1], [0, 1], transformax1.transAxes, **kwargs) ax1.plot([1 - d, 1], [1, 0], transformax1.transAxes, **kwargs) ax2.plot([0, d], [0, 1], transformax2.transAxes, **kwargs) ax2.plot([0, d], [1, 0], transformax2.transAxes, **kwargs)在这个版本里d表示斜线在 Axes 宽度方向上的比例默认 0.5 表示斜线跨度是半轴宽度。对于只有一个子图宽的断口d 可以适当调小因为你不需要让斜线伸入太多。横向断轴在仪表盘里比较少见因为大多数人习惯纵向看 y 轴断裂但它在处理时间轴上很实用尤其是“凌晨低谷到白天高峰”这种场景。4.3 多段断轴如何扩展有时候数据分成三段比如 0 到 50、600 到 700、1500 到 1600你能不能画一个两处断口的图可以但代价是代码复杂度会明显上升。你需要创建三个共享 x 轴的 Axes用gridspec或者subplots生成三行子图然后按顺序隐藏相邻边界和刻度。中间的两处断口分别画锯齿标记。多段断轴有一个必须注意的问题三段的坐标缩放比例必须和你要表达的数据“绝对尺度”保持一致吗答案是看需求。断轴本就不保留绝对尺度每段的高度是独立缩放的所以三段之间的曲线斜率不能直接比较。如果你的业务场景需要比较斜率变化比如“前半段上升速度 vs 中间段上升速度”多段断轴会带来严重误导。那种情况下不如用正常轴加注释框或者用对数轴。多段断轴更适合只需要观察“每段内部形状”的场景。如果你真的要写多段我建议不要用subplots直接创建而是用fig.add_axes手动控制每个 Axes 的position这样你能精确控制每段高度。比如低区间数据波动小可以给低段分配更高高区间数据波动大可以给高段分配矮一些。手动布局的灵活性比自动 subplots 高很多。5. 常见问题与排查技巧实录5.1 断口线不显示或位置乱跑这是断轴新手最常踩的坑。断口线不显示最普遍的原因是clip_on没有设置为False。因为断口 marker 画在 Axes 边缘默认的绘图区域会把它裁剪掉尤其是marker有一部分在 Axes 外时。你经常看到两个 Axes 中间干干净净什么都没有就是这个问题。还有一个原因是把transAxes写错了比如误写成了ax1.transData。一旦变成数据坐标斜线就会跟着数据范围跑甚至在大量数据下变成一片黑。确认方法很简单把要画断口的 Axes 单独拿出来只执行一次对应代码然后放大窗口看坐标位置。如果斜线出现在 Axes 左下角或右下角附近说明transform用对了如果斜线出现在曲线中间说明用成了数据坐标。另外如果你的图片导出后断口线变得很细或者看不见可以把markersize调大一点比如 14同时把mewmarker 边框宽度从 1 调到 1.5。marker 的尺寸和线宽在不同后端渲染下表现不同保存成 PDF 和放大 PNG 时尤其明显。5.2 刻度线和标签残留恢复不干净断轴图画出来之后断口附近如果还有刻度线看起来就会很脏。原因通常是你在隐藏 spine 和 tick 的时候只处理了其中一条边。上子图隐藏bottomspine 后还需要用ax1.tick_params(bottomFalse)关掉下边缘的刻度线下子图隐藏topspine 后还需要用ax2.tick_params(topFalse)。但请记住tick_params的参数分为bottom和labelbottom两类。如果只关了刻度的显示刻度标签可能还在反过来也一样。常见情况是因为sharexTrue上子图的 x 轴刻度标签默认不再显示但你仍会看到上子图底部冒出一些刻度短线。这是因为tick_params(bottomFalse)没写。另一类残留是左右 spine 上的刻度线穿过断口这其实不算 bug因为左右刻度本来就应该存在。如果你觉得左右刻度线与断口斜线重叠可以把断口 marker 的zorder调高比如在 kwargs 里加zorder5让断口标记盖住左侧刻度线。5.3 掉进 tight_layout 的坑里很多人在调整完hspace0.05后又顺手调用fig.tight_layout()结果发现两个 Axes 的间隙突然变大了断轴效果完全失效。这是因为tight_layout()会重新计算所有子图的位置它会根据刻度标签占用的空间重新分配布局把你手调的subplots_adjust覆盖掉。如果你必须用tight_layout()可以有两种处理方式第一种是先调用tight_layout()再调用fig.subplots_adjust(hspace0.05)覆盖回去因为后调用者生效第二种是彻底不用tight_layout()改为手动设置fig.subplots_adjust(left0.1, right0.95, top0.95, bottom0.1)这样布局完全可控断口间隙绝对不会被自动布局冲掉。我个人的习惯是断轴图里尽量少用自动布局因为断轴的“位置精度”比一般图要高自动布局带来的方便远小于它带来的不确定性。5.4 到底什么时候应该用断轴最后我分享一些实际项目里总结的判断标准不一定全面但很实用。断轴适合的情况有这么几类你有一组数据绝大部分值集中在窄区间少数值出现在数量级完全不同的区间你想同时展示两个区间的细节并且可以接受丢失 x 轴比例上的连续性你希望给汇报对象看图时对方能一眼看出“中间是断开的”。如果满足其中两条断轴基本是合理的。反过来如果数据只是轻微抖动用普通坐标轴加局部放大图更好没必要断轴。如果数据跨度跨越好几个数量级且呈指数趋势优先考虑对数轴。如果需要精确比较两段曲线的斜率或者面积断轴会产生误导最好用其它方式展示。断轴不是为了让图更炫而是为了让图更诚实。开图时看到锯齿标记就应该意识到坐标不连续这是断轴的基本规则。我在实际中做过一个折中先用完整量程的迷你图展示整体数据形状再用断轴图展示局部细节。这样既保留了对整体趋势的感知又满足了细节展示需求。这个方法尤其适合做数据报告的封面图比单独一张断轴图信息更完整也更不容易引起误读。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑