首页/Python 与数据实践/Matplotlib 绘图体系
Python 与数据实践 · 第 29 章

Matplotlib 绘图体系

数据这条流水线走到头了:第 25 章在 Jupyter 里试算,第 26 章用 NumPy 把计算向量化,第 27、28 章用 Pandas 把表洗好、聚合完。结论都躺在内存里,只差最后一道工序——把它变成别人一秒能看懂…

约 17 分钟6,818 字14 个代码示例

数据这条流水线走到头了:第 25 章在 Jupyter 里试算,第 26 章用 NumPy 把计算向量化,第 27、28 章用 Pandas 把表洗好、聚合完。结论都躺在内存里,只差最后一道工序——把它变成别人一秒能看懂的图。这道工序的主力工具就是 Matplotlib:它在 Python 画图界的资历最老,后面你要遇到的高级库(包括第 31 章的 Seaborn)底下都垫着它。所以这一章不急着堆图表花样,先干一件更重要的事:把 Matplotlib 的骨架摸清楚——一张图到底由哪几层对象叠出来,每层各自归谁管。骨架清楚了,后面不管学什么可视化库,都是换个姿势拧同一批螺丝。

1. 开工前的两件小事

1.1 装库

uv add matplotlib          # 或者:pip install matplotlib

如果你主要在 Notebook 里干活,顺手把 ipympl 也装上——它让画出来的图可以在单元格里拖拽缩放,后面两章的动画和交互组件都靠它。启用方式是一行魔法命令:

%matplotlib widget   # 仅 Notebook 环境有效,脚本不用管

1.2 让中文和负号正常显示

Matplotlib 默认字体不含中文,直接画中文标题会得到一排方框。开工前先把字体配置敲定:

import numpy as np
import matplotlib.pyplot as plt

# 按自己的系统挑一个存在的字体;列表里排前的优先命中
plt.rcParams.update({
    "font.sans-serif": ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"],
    "axes.unicode_minus": False,   # 修复负号显示成方块的问题
    "figure.dpi": 100,
})

本章所有代码默认这两件事已经办完。

2. 一张图,是三层对象叠出来的

先建立一个观察框架。任何统计图拆到最底层,都是三样东西:数据、几何对象(点、线、柱、面)、视觉属性(位置、颜色、大小、形状)。这套拆法有个学名,叫"图形语法"(grammar of graphics)——有的库把它做成了声明式语法(你说"画什么",它决定"怎么画"),而 Matplotlib 走的是另一条路:像搭积木一样,一层层手动布置。手动麻烦一点,但每一块积木都攥在你手里。

在 Matplotlib 的积木盒里,有三块基础积木,对应三层结构:

对象 角色 打个比方
Figure 顶层容器,管整张图 办展览用的整块展板
Axes 真正画数据的一块区域 展板上划出的一个个展格
Artist 画面上一切可见的东西 展格里摆的一件件展品

三层关系长这样:

Figure(展板)
 ├── Axes(展格 1)
 │    ├── Axis(横轴)→ 刻度、刻度标签都是它管的
 │    ├── Axis(纵轴)
 │    ├── Line2D(折线展品)
 │    ├── Rectangle(柱子展品)
 │    ├── Text(标题、轴标签、标注……)
 │    └── Legend(图例)
 ├── Axes(展格 2)
 │    └── ……
 └── ……

先记住一句心法,后面每一节都在印证它:Figure 管全局,Axes 管数据与坐标,Artist 管长相。遇到任何图表需求,先判断"这事儿该找哪层",再去查对应那层的 API——这是本章最想塞给你的东西。

3. Figure:掌管整块展板

3.1 起手式:一次拿到展板和展格

日常画图,99% 的情况都从这一行开始:

fig, ax = plt.subplots(figsize=(8, 4))   # 8 英寸宽、4 英寸高的展板 + 一块展格

months = np.arange(1, 13)
sales = np.array([21, 19, 24, 27, 31, 36, 39, 38, 33, 29, 26, 32])  # 咖啡店月销售额(万元)

ax.plot(months, sales, marker="o")

plt.subplots() 一手交两样:fig 是展板,ax 是从展板上划出来的展格。数据一律画在 ax 上——这是 Matplotlib 官方推荐的"面向对象"写法,比 plt.plot() 那种全局式写法干净得多,尤其是多图共存的时候。

3.2 一板多格:多子图

一块展板当然可以划多块展格。给 subplots() 传行列数,拿到的是一个展格数组:

fig, axes = plt.subplots(2, 2, figsize=(10, 8))

days = np.arange(1, 366)
temp = 15 + 11 * np.sin((days - 100) / 365 * 2 * np.pi)      # 一年的日均气温

axes[0, 0].plot(months, sales)                                  # 折线:月销售额
axes[0, 1].bar(["一季度", "二季度", "三季度", "四季度"],
               [55, 94, 110, 87])                               # 柱状:季度杯量
axes[1, 0].scatter(temp[:100], 320 - 7 * temp[:100])           # 散点:气温 vs 热饮订单
axes[1, 1].hist(np.random.normal(38, 9, 3000), bins=40)        # 直方图:单笔消费金额

四个格子各画各的,互不干涉。要给整块展板加一个总标题,用 fig.suptitle()——注意这活儿是 Figure 的,不是某个 Axes 的。

3.3 展板级装修:一键换风格

同一份数据,换个整体风格观感就完全不同。plt.style 提供了一键装修:

print(plt.style.available)   # 看看库里都有哪些现成风格

输出会列出一串名字,比如 seaborn-v0_8、ggplot、bmh、dark_background……挑一个套上:

plt.style.use("ggplot")

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(months, sales)
ax.bar(months, sales * 0.5, alpha=0.5)

plt.style.use("default")   # 用完记得还原,否则风格会一直"粘"在会话上

ggplot 风格自带灰底和网格,配色也换了一套。风格影响的是所有 Axes 的底色、网格、字体、配色——典型的"Figure 管全局"。

4. Axes:数据真正落笔的地方

4.1 先辨析一对易混词:Axes ≠ Axis

这两个词长得像亲兄弟,其实是两回事:

  • Axis(单数):一根坐标轴,管刻度、刻度标签、轴本身的样式;
  • Axes(复数):一块完整的绘图区,通常自带两根 Axis。

可以这么记:Axes 是"坐标系",Axis 是"坐标轴"。一块展格里通常住着两根轴。

4.2 展格定制六件套

画完数据,展格这边还有一排开关可拧:标题、轴标签、取值范围、图例、网格。拿"两座城市全年气温对比"一次配齐:

fig, ax = plt.subplots(figsize=(8, 4))

days = np.linspace(0, 365, 200)
north = 13 + 12 * np.sin((days - 105) / 365 * 2 * np.pi)    # 北方城市:冬夏分明
south = 24 + 4 * np.sin((days - 105) / 365 * 2 * np.pi)     # 南方城市:四季温和

ax.plot(days, north, label="北方城市")
ax.plot(days, south, label="南方城市")

ax.set_title("两座城市的年气温对比")
ax.set_xlabel("一年中的第几天")
ax.set_ylabel("气温(℃)", rotation=0, labelpad=20)   # rotation=0 让 y 标签横躺
ax.set_xlim(0, 365)
ax.set_ylim(-5, 40)
ax.legend()
ax.grid(True, linestyle=":", alpha=0.5)

效果:一条振幅大的正弦波与一条振幅小的正弦波并肩起伏,右上角图例注明两座城市,虚线网格衬底。两个细节值得记:label= 配 ax.legend() 是图例的标准打开方式;set_ylabel(rotation=0) 把纵轴标签从竖排改成横排,中文场景下常常更好读。

4.3 3D:给展格多加一根轴

Axes 不止两维。创建展格时声明 projection="3d",它就升级成三维展格,多出一根 z 轴:

fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111, projection="3d")

price = np.linspace(12, 30, 60)      # 咖啡定价(元/杯)
ads = np.linspace(0, 8, 60)          # 月广告投入(千元)
P, A = np.meshgrid(price, ads)

# 模拟月利润(千元):定价偏离 18 元、投放偏离 4 千元都会掉
demand = 8000 * np.exp(-((P - 18) ** 2) / 40) * np.exp(-((A - 4) ** 2) / 6)
profit = demand * (P - 8) / 1000 - A * 1.5

ax.plot_surface(P, A, profit, cmap="viridis")
ax.set_xlabel("定价(元/杯)")
ax.set_ylabel("月广告(千元)")
ax.set_zlabel("月利润(千元)")
ax.set_title("咖啡店利润曲面")

画出来是一顶"小山包"曲面:山顶在定价 18 元、投放 4 千元附近,坡面颜色随高度渐变。三维展格同样可以拖拽旋转视角。同目录的 3d.py 是这一节的完整可运行版本。

5. Artist:画面上每个可见物,都是能摸的对象

第三层压轴出场。你在画面上看到的一切——折线、柱子、散点、标题、图例、甚至坐标轴本身——统统是 Artist 对象。前两节你其实已经在不停创建 Artist 了,只是没回头细看:

fig, ax = plt.subplots(figsize=(8, 4))

curve, = ax.plot(months, sales, color="royalblue", linewidth=3, label="月销售额")
bars = ax.bar([14, 15, 16], [30, 35, 28], color="tomato",
              edgecolor="black", alpha=0.7, label="新门店试营业")
dots = ax.scatter([17, 18, 19], [40, 26, 33], color="green", s=120, label="活动日")

ax.legend()

注意 ax.plot(...) 的返回值——那就是刚画上去的折线 Artist 本尊。每类 Artist 都带一排可调属性:

  • 折线:color、linewidth、linestyle……
  • 柱子:edgecolor(描边)、alpha(透明度)、hatch(纹理)……
  • 散点:s(点的大小)、marker(点的形状)……

更关键的潜台词:既然是对象,画上去之后随时能改。curve.set_color("crimson")、dots.set_sizes([300, 100, 200]),改完刷新即生效——下一章做动画和交互,全靠"事后改 Artist"这一招,而不是把图擦了重画。

6. 坐标轴精修:脊线、箭头标注与文字

6.1 spines:展格的四条边

展格四周有四条边框线,学名 spines(脊线)。商务图表里流行的"干净风格",一半的秘诀就是砍掉上面和右面那两条:

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(months, sales, linewidth=2)

ax.spines["top"].set_visible(False)        # 砍掉上边
ax.spines["right"].set_visible(False)      # 砍掉右边
ax.spines["left"].set_color("gray")        # 左边调淡
ax.spines["bottom"].set_linewidth(1.5)     # 底边加粗

视觉重心立刻落到数据上——留白多了,线条的主角感就出来了。

6.2 annotate 与 text:往图上"贴便签"

光有曲线不够,有时候得指着某一点说话。annotate 负责带箭头的标注,text 负责放一段自由文字:

fig, ax = plt.subplots(figsize=(8, 4))

x = np.arange(1, 13)
y = np.array([21, 19, 24, 27, 31, 36, 39, 38, 33, 29, 26, 32])
ax.plot(x, y, marker="o")

ax.annotate(
    "暑期峰值",                          # 标注文字
    xy=(7, 39), xytext=(4.5, 37),       # xy:箭头指哪;xytext:文字放哪
    arrowprops={"arrowstyle": "->", "color": "red"},
    fontsize=12,
)
ax.text(
    1, 22, "旺季与淡季的差距接近一倍",
    fontsize=13, style="italic",
    bbox={"boxstyle": "round", "facecolor": "wheat", "alpha": 0.5},
)

xy 是箭头要指的坐标点,xytext 是文字落脚的位置;bbox 给文字垫了个圆角底色框。做汇报图时,这两招能把"你自己看"升级成"我讲给你听"。

7. 收束:拿到需求,先问三层

把整章压缩成一张排查地图:

层 管什么 典型操作
Figure 画布与全局观感 figsize、suptitle、plt.style.use、导出尺寸
Axes 数据、坐标、图例 plot/bar/scatter、set_title、set_xlim、legend、grid
Artist 每个可见元素的视觉细节 color、linewidth、alpha、s、事后 set_*

下次面对一个图表需求,先在心里过一遍:尺寸和整体风格的事,找 Figure;画什么、坐标范围多大、图例放哪,找 Axes;某个元素的颜色线宽点大小,找那个 Artist 的属性。三层各回各家,查文档都不用大海捞针——Matplotlib 的 API 文档正是按这三层组织的。

试一试

一、盲改练习

下面这段代码画出来的图,中文标题会显示成方框,而且 y 轴刻度里有负号也会出问题。只改两行以内,把它修好(提示:本章第 1 节):

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
ax.plot([1, 2, 3], [-1, -4, -9])
ax.set_title("每月亏损趋势")

二、三层归属分类

把下列八个操作分到 Figure / Axes / Artist 三层:figsize=(10, 6)、ax.set_ylim(0, 100)、plt.style.use("bmh")、ax.legend(loc="upper left")、ax.spines["top"].set_visible(False)、ax.annotate("峰值", xy=(5, 40))、fig.suptitle("年度总览")、dots.set_sizes([100, 200, 300])。

三、四格看板

用 plt.subplots(2, 2) 画一块四格看板:折线、柱状、散点、直方图各占一格,并给每格配标题。概念全景的可运行版本在同目录 demo.py 里——先自己搭,搭完再对。


静态图到此已经尽在掌握。但有些故事静态图讲不好:过程怎么演变、数据怎么滚动、参数怎么影响结果——这些得让图"动起来",甚至让看图的人"伸手进去调"。下一章给 Matplotlib 装上时间轴和操纵杆。


下一章:30 · Matplotlib 动画与交互