1. 从一张折线图说起:为什么第二篇才是真正开始上手
很多人学 Matplotlib 的路径都差不多:找一篇入门教程,照着敲一遍plt.plot([1,2,3],[4,5,6]),看到窗口弹出一根线,心里想“哦,原来就这么简单”,然后关掉编辑器,下次真正要画图的时候又打开搜索引擎从头查。我自己当年也是这样,第一篇教程让我认识了plot和show,但真正让我把 Matplotlib 用起来的,是第二篇——也就是开始处理多子图、样式控制、中文显示、图例标注、保存输出这些“真实项目里一定会遇到”的东西。
这篇内容就是围绕“快速上手”的第二阶段展开的。它适合已经能画出单条曲线、但对着一张图里塞多个子图、想改颜色线型、想让中文正常显示、想把图存成高清文件这些操作还不太熟的人。我会把这一阶段最核心的几个技术点拆开讲清楚:Figure 与 Axes 的关系、subplots 布局、样式与配色、中文与负号显示、图例与标注、保存与分辨率控制。每一个点我都会说明“为什么要这么做”,而不只是“代码这样写”。
先给一个整体判断:Matplotlib 的学习曲线之所以让很多人觉得陡,不是因为 API 难,而是因为它的两套接口(pyplot 状态机接口和面向对象接口)混在一起,新手分不清什么时候用plt.xxx,什么时候用ax.xxx。第二阶段的第一个任务,就是把这个关系理顺。理顺之后,你会发现后面所有操作都顺了。
2. 核心概念拆解:Figure、Axes、Axis 到底谁是谁
2.1 一张图的三层结构
刚接触 Matplotlib 的人最容易被三个词绕晕:Figure、Axes、Axis。我用一个生活化的类比来解释:把画图想象成做一面照片墙。
- Figure(画布):整面墙。它是一张白纸,是所有内容的容器,本身不画任何数据。
- Axes(坐标系/子图):墙上挂的每一个相框。一个 Figure 上可以挂多个相框,每个相框里有自己的坐标轴、自己的数据。注意 Axes 是“一个绘图区域”,不是“坐标轴”。
- Axis(坐标轴):相框里的横轴和纵轴,负责刻度、标签、范围。
很多人第一次看到fig, ax = plt.subplots()会懵:为什么返回两个东西?现在你应该明白了——fig是那面墙,ax是墙上的那个相框。你要画数据,永远是在ax上画,而不是在fig上画。
提示:
Axes和Axis只差一个字母,但含义完全不同。前者是绘图区域,后者是坐标轴。看官方文档时千万别看串。
2.2 两套接口:什么时候用 plt,什么时候用 ax
这是第二阶段必须跨过的一道坎。pyplot 接口(plt.plot、plt.title)是状态机风格,它默认操作“当前活跃的那个 Axes”。面向对象接口(ax.plot、ax.set_title)是显式风格,你明确指定在哪个 Axes 上操作。
单图简单场景,两者没区别。但只要涉及多子图,pyplot 接口就会出问题——你plt.plot的时候,它到底画在哪个子图上?答案是“最后创建或最后激活的那个”,这非常容易出错。所以我的建议很直接:
- 只要超过一个子图,一律用面向对象接口。
- 单图也别用
plt.xxx混着写,统一用ax.xxx,代码可维护性高一个档次。
这里有个细节很多人不知道:plt.title()和ax.set_title()功能一样,但命名不统一。pyplot 里是title,面向对象里是set_title。类似的还有xlabel→set_xlabel,xlim→set_xlim。记住这个规律:面向对象接口里,设置类的操作大多带set_前缀,取值类的操作带get_前缀。
2.3 为什么推荐 subplots 而不是 add_subplot
创建子图有两种常见写法。老写法是:
fig = plt.figure() ax1 = fig.add_subplot(2, 2, 1) ax2 = fig.add_subplot(2, 2, 2)新写法是:
fig, axes = plt.subplots(2, 2)我强烈推荐后者,原因有三个。第一,subplots一次性返回所有 Axes 组成的数组,你可以用axes[0, 1]直接索引,不用记add_subplot那三个数字参数的含义。第二,subplots支持sharex、sharey参数,多子图共享坐标轴时特别省事。第三,subplots内部帮你处理了布局,配合constrained_layout能自动解决子图重叠问题。
add_subplot(2, 2, 1)里的三个数字含义是“2 行 2 列,第 1 个”,编号从左上角开始,按行优先递增。这个规则要记牢,因为subplots返回的二维数组索引axes[行, 列]和它是对应的,都是从 0 开始。
3. 多子图布局:把一张画布切得明明白白
3.1 subplots 返回值的索引陷阱
fig, axes = plt.subplots(2, 3)之后,axes是一个 2 行 3 列的二维数组。访问方式:
axes[0, 0] # 第一行第一列 axes[1, 2] # 第二行第三列但如果你只创建一个子图,plt.subplots()返回的ax是单个对象,不是数组。而plt.subplots(1, 1)返回的axes是一维数组。这个不一致性坑过无数人。我的处理习惯是:创建时永远显式写出行列数,然后用axes.flat或者np.ravel(axes)把它拍平成一维,再配合循环处理,代码最干净。
fig, axes = plt.subplots(2, 3, figsize=(12, 8)) for i, ax in enumerate(axes.flat): ax.plot(data[i]) ax.set_title(f"子图 {i+1}")axes.flat是一个迭代器,能按行优先顺序遍历所有子图,比手写双重循环优雅得多。
3.2 共享坐标轴:sharex 与 sharey 的正确用法
多子图对比数据时,如果每个子图的坐标轴范围不一样,读者很难直观比较。sharex=True让所有子图共用同一个 x 轴范围,缩放时联动。这在画时间序列对比时特别有用。
fig, axes = plt.subplots(3, 1, sharex=True, figsize=(10, 8))注意一个细节:共享坐标轴后,只有最底部的子图会显示 x 轴刻度标签,中间的会被自动隐藏,这是有意设计的,避免重复。如果你确实需要每个子图都显示标签,得手动ax.tick_params(labelbottom=True)。
还有一个坑:sharex和sharey必须在创建时就指定,创建后没法改。所以布局前要想清楚。
3.3 布局重叠怎么办:constrained_layout 与 tight_layout
子图标题、坐标轴标签、图例这些东西很容易互相重叠,尤其是子图多、标签长的时候。Matplotlib 提供了两种自动布局方案:
| 方案 | 用法 | 特点 |
|---|---|---|
| tight_layout | plt.tight_layout() | 老方案,简单,但对复杂布局支持一般 |
| constrained_layout | plt.subplots(layout="constrained") | 新方案,推荐,支持 colorbar、suptitle 等 |
我的建议是优先用 constrained_layout,它在创建 Figure 时通过参数开启,之后每次绘图都会自动调整,不需要手动调用。tight_layout是事后调用,如果图已经画完再调,有时会失效。
fig, axes = plt.subplots(2, 2, layout="constrained", figsize=(10, 8))如果 constrained_layout 还是解决不了(比如你手动用了fig.add_axes绝对定位),那就只能手动调subplots_adjust,用left、right、top、bottom、wspace、hspace这几个参数微调。wspace和hspace是子图之间的水平和垂直间距,单位是“平均子图宽/高的比例”,默认 0.2 左右。
4. 样式控制:让图从“能看”变成“好看”
4.1 颜色、线型、标记点三件套
plot函数最常用的三个样式参数是color、linestyle、marker。它们可以写成简写字符串,比如"ro--"表示红色、圆点标记、虚线。但我更推荐用完整参数名,可读性高:
ax.plot(x, y, color="steelblue", linestyle="--", marker="o", markersize=6)颜色有三种指定方式:颜色名("red"、"steelblue")、十六进制("#1f77b4")、RGB 元组((0.1, 0.2, 0.3))。线型常用"-"(实线)、"--"(虚线)、"-."(点划线)、":"(点线)。标记点常用"o"(圆)、"s"(方)、"^"(三角)、"*"(星)。
这里有个经验:线宽和标记点大小要匹配。线宽默认 1.5,标记点默认 6,如果线加粗到 3,标记点也要相应放大到 10 左右,否则视觉上不协调。
4.2 全局样式:rcParams 与 style
如果你每次画图都要设置字体大小、线宽、网格,一个个改太累。Matplotlib 提供了rcParams全局配置:
import matplotlib as mpl mpl.rcParams["font.size"] = 12 mpl.rcParams["axes.grid"] = True mpl.rcParams["lines.linewidth"] = 2更省事的是用内置样式表:
plt.style.use("seaborn-v0_8-whitegrid")plt.style.available能列出所有可用样式。我常用的有"ggplot"(模仿 R 的 ggplot2)、"seaborn-v0_8"系列、"bmh"。样式表可以在创建 Figure 前设置,也可以临时用with plt.style.context("ggplot"):局部生效。
注意:样式表会覆盖你之前设置的 rcParams,所以顺序很重要。先
style.use,再改 rcParams 微调。
4.3 网格、边框、刻度:细节决定质感
默认的 Matplotlib 图有个通病:四周有边框,刻度朝外,网格默认关闭,看起来比较“朴素”。稍微调一下就能提升质感:
ax.grid(True, linestyle=":", alpha=0.6) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.tick_params(direction="in", length=4)spines是四个边框,去掉顶部和右侧是数据可视化的常见做法,减少视觉干扰。tick_params的direction="in"让刻度朝内,看起来更紧凑。alpha控制透明度,网格线用 0.5 到 0.7 之间比较合适,太深会抢数据的视觉焦点。
5. 中文显示与负号:新手最容易卡住的地方
5.1 中文乱码的根因
Matplotlib 默认字体是英文字体,不含中文字形,所以中文会显示成一个个方框(俗称“豆腐块”)。解决办法是设置一个支持中文的字体。不同操作系统自带的字体不一样:
| 系统 | 常用中文字体 |
|---|---|
| Windows | SimHei(黑体)、Microsoft YaHei(微软雅黑) |
| macOS | PingFang SC、Heiti SC |
| Linux | WenQuanYi Micro Hei、Noto Sans CJK |
设置方式:
mpl.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"] mpl.rcParams["axes.unicode_minus"] = Falsesans-serif是一个字体列表,Matplotlib 会按顺序找第一个系统里存在的。把多个平台的字体都列上,代码就能跨平台运行。
5.2 负号变方块的坑
设置中文字体后,很多人会发现负号变成了方块。原因是很多中文字体里的负号字形和 ASCII 减号不兼容。解决办法就是上面那行axes.unicode_minus = False,让 Matplotlib 用 ASCII 减号代替 Unicode 负号。
这个坑非常隐蔽,因为你不画负数就发现不了。我建议设置中文字体的同时就把 unicode_minus 关掉,一步到位。
5.3 字体缓存问题
有时候你明明设置了字体,还是显示方块。这通常是字体缓存的问题。Matplotlib 会把系统字体扫描结果缓存起来,新装的字体可能没被识别。清理缓存:
import matplotlib print(matplotlib.get_cachedir())打印出缓存目录后,删掉里面的字体缓存文件,重启 Python 即可。或者直接调用matplotlib.font_manager._rebuild()强制重建。
6. 图例、标注与文本:让图自己会说话
6.1 图例的两种添加方式
图例有两种加:一种是在plot时用label参数,最后统一ax.legend();另一种是手动传句柄和标签。推荐第一种:
ax.plot(x, y1, label="方案 A") ax.plot(x, y2, label="方案 B") ax.legend(loc="upper right", frameon=False)loc控制位置,常用"best"(自动找不挡数据的位置)、"upper right"、"lower left"。frameon=False去掉图例边框,视觉更干净。ncol参数可以设置图例列数,图例项多的时候横排更省空间。
6.2 数据点标注:annotate 的用法
想在某个数据点旁边加文字说明,用annotate:
ax.annotate("峰值", xy=(5, 10), xytext=(6, 12), arrowprops=dict(arrowstyle="->", color="gray"))xy是被标注点的坐标,xytext是文字位置,arrowprops控制箭头样式。如果不想画箭头,去掉arrowprops即可。arrowstyle常用"->"、"-|>"、"fancy"。
6.3 文本与标题的层级
一张规范的图应该有:主标题(set_title)、坐标轴标签(set_xlabel/set_ylabel)、必要时加副标题。Matplotlib 没有原生副标题,但可以用fig.suptitle加总标题,或者用ax.text在标题下方手动加。
ax.set_title("月度销售趋势", fontsize=14, fontweight="bold") ax.set_xlabel("月份", fontsize=11) ax.set_ylabel("销售额(万元)", fontsize=11)字号建议:标题 14 左右,轴标签 11 左右,刻度 10 左右。fontweight="bold"让标题加粗,层次更分明。
7. 保存与输出:分辨率、格式、边距
7.1 dpi 与尺寸的关系
保存图片时两个关键参数:figsize(英寸)和dpi(每英寸点数)。最终像素 = figsize × dpi。比如figsize=(10, 6)、dpi=300,输出就是 3000×1800 像素。
fig.savefig("output.png", dpi=300, bbox_inches="tight")屏幕显示用 100 左右 dpi 就够,论文或印刷用 300,海报用 600。bbox_inches="tight"会自动裁掉多余白边,非常实用,但要注意它可能裁掉部分标注文字,必要时配合pad_inches留白。
7.2 矢量图 vs 位图
格式选择上,PNG 是位图,适合屏幕展示;PDF、SVG 是矢量图,放大不失真,适合论文和排版。我的习惯是同时存一份 PNG 和一份 PDF,PNG 用于快速预览,PDF 用于正式场合。
fig.savefig("output.pdf", bbox_inches="tight") fig.savefig("output.png", dpi=300, bbox_inches="tight")注意:保存必须在
plt.show()之前。show()之后 Figure 可能被清空,再保存就是空白图。这个坑我踩过不止一次。
7.3 透明背景与颜色模式
需要把图叠到其他背景上时,用transparent=True保存透明背景 PNG。如果图要用于深色主题,记得把文字和线条颜色改成浅色,否则黑字黑线在深色背景上看不见。
8. 常见问题与排查速查表
8.1 问题速查
| 现象 | 可能原因 | 解决 |
|---|---|---|
| 中文显示方块 | 字体不含中文 | 设置 font.sans-serif |
| 负号显示方块 | 字体负号不兼容 | axes.unicode_minus=False |
| 子图重叠 | 布局未调整 | layout="constrained" |
| 保存空白图 | show 后再保存 | 保存放 show 前 |
| 图例不显示 | 没传 label | plot 时加 label |
| 刻度太密 | 范围大 | 用 MaxNLocator 控制 |
| 线条锯齿 | dpi 低 | 提高 dpi 或存矢量图 |
8.2 几个独家避坑经验
第一,不要在循环里反复plt.figure()。每次调用都会创建新画布,内存里堆一堆 Figure,最后show的时候可能只显示最后一个。正确做法是循环外创建一次,循环内用ax.clear()或直接操作不同 Axes。
第二,plt.show()在脚本和 Notebook 里行为不同。脚本里它会阻塞直到关窗口,Notebook 里它自动渲染。如果你在脚本里想连续显示多张图,得用plt.show(block=False)或者干脆存文件。
第三,颜色循环(color cycle)是全局的。如果你在同一进程里画了多张图,颜色会接着上一张继续轮换,导致两张图颜色不一致。解决办法是每次画图前ax.set_prop_cycle(None)重置,或者显式指定颜色。
第四,tight_layout和suptitle会打架。用了fig.suptitle之后调tight_layout,总标题可能被裁掉。这时候改用constrained_layout或者手动subplots_adjust(top=0.9)。
9. 一个完整的实操案例:四子图对比图
把前面所有知识点串起来,做一个完整的例子。需求:画一个 2×2 的子图,分别展示四种不同参数下的曲线,共享 x 轴,带中文标题、图例、网格,最后存成高清 PNG 和 PDF。
import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt mpl.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"] mpl.rcParams["axes.unicode_minus"] = False mpl.rcParams["font.size"] = 11 x = np.linspace(0, 10, 200) params = [("线性", x), ("平方", x**2), ("正弦", np.sin(x)), ("指数", np.exp(x/5))] fig, axes = plt.subplots(2, 2, figsize=(11, 8), sharex=True, layout="constrained") for ax, (name, y) in zip(axes.flat, params): ax.plot(x, y, color="steelblue", linewidth=2, label=name) ax.set_title(f"{name}函数", fontsize=13, fontweight="bold") ax.grid(True, linestyle=":", alpha=0.6) ax.legend(loc="upper left", frameon=False) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) for ax in axes[1, :]: ax.set_xlabel("x 轴") for ax in axes[:, 0]: ax.set_ylabel("y 轴") fig.suptitle("四类函数对比", fontsize=15, fontweight="bold") fig.savefig("compare.png", dpi=300, bbox_inches="tight") fig.savefig("compare.pdf", bbox_inches="tight") plt.show()这段代码里,sharex=True让四个子图共享 x 轴,只有底部两个显示 x 标签;layout="constrained"自动处理了总标题和子图的间距;axes.flat把二维数组拍平方便循环;spines去掉了顶部和右侧边框。跑一遍你会发现,出来的图直接就能放进报告里。
10. 性能与进阶方向的一点个人体会
画少量数据时,Matplotlib 的性能完全够用。但如果你要画几十万点的散点图,默认的plot会明显卡顿。这时候有几个优化方向:用scatter时设置rasterized=True,让点在保存矢量图时被栅格化;或者用LineCollection批量画线;再或者直接换datashader这类专门处理大数据的库。
另一个进阶方向是自定义样式表。你可以把自己常用的一套配置写成一个.mplstyle文件,放在配置目录里,之后plt.style.use("my_style")一键调用。样式表就是纯文本,每行一个key: value,比如axes.grid: True、lines.linewidth: 2。团队协作时统一一份样式表,出来的图风格一致,省去大量沟通成本。
我自己在实际项目里的体会是:Matplotlib 的“快速上手”其实分两个阶段,第一阶段是能画出图,第二阶段是能画出规范、可复用、可交付的图。第二阶段的这些知识点——subplots 布局、样式控制、中文处理、保存输出——看起来零散,但它们共同决定了你的图能不能直接拿去用。把这些吃透之后,你再去学 Seaborn、Plotly 这些高层库,会发现底层逻辑都是相通的,上手速度会快很多。
最后分享一个小技巧:养成把常用配置写成一个setup_plot()函数的习惯,每次新项目开头调用一次,把字体、字号、网格、边框这些一次性设好。这样你就能把精力集中在数据本身,而不是每次都在调样式上浪费时间。