本章

高级篇 / 第 21 章

第 21 章 · 数据可视化实战

本章导读:数据分析的最后一公里是「让别人看懂」。本章从图形选型讲起,用 Matplotlib(Python 绘图的事实标准)打通 Figure/Axes 建模、折线/柱状/饼图/直方图/散点图、样式与子图布局、标注与标注框、中文字体与负号显示,再用 Seaborn 一行代码画出统计图,最后用 Plotly 做交互式网页图表与 Dash 仪表板。学完你能把任意一张 DataFrame 变成一张能直接放进汇报里的图。

可视化的本质与图形选型

1983 年,William Cleveland、William McGill、Jerdeen Terpenning 发表了一篇经典论文《Graphical Perception》,用心理物理实验量化了人眼对不同图形的判读精度。核心结论是:人类从「位置和长度」读取数值的能力,远强于从「角度、面积、颜色」读取数值。

图形编码方式人眼判读精度适用场景禁忌
折线图(line)位置(position)高时间序列、趋势变化类目过多时折成一团;分类数据不要用
柱状图(bar)长度 / 位置高类目间比较(少量到几十个)类目超过 30 个应改横向条形或折线
点图 / 散点图(scatter)位置高两个连续变量的相关性数据点超过 1 万要抽样或用 hexbin
直方图(histogram)面积(分箱后的位置)中单个连续变量的分布不要用饼图表示连续分布
饼图(pie)角度 / 面积低仅 2~5 个类目的占比超过 5 块、块之间差距小于 10% 时失效
箱线图(box)位置 + 五数概括高分组分布对比、异常值数据量极少(<10)时不稳定
热力图(heatmap)颜色深浅低矩阵型数据(相关系数、混淆矩阵)色带选错会误导;必须标数值
饼图/环形图变体角度低只有两三个占比时几乎总能被条形图替代
常见坑「饼图看起来更直观」是错觉。凡是数据要进行比较(谁比谁大多少),条形图的精度都碾压饼图。面试里被问「饼图和柱状图怎么选」,标准答案是:比较大小用柱状图,展示整体与部分的关系且类目不超过 5 个才考虑饼图。

一张决策流程图

  1. 你想表达的是变化趋势吗?→ 折线图(横轴必须有意义,通常是时间)。
  2. 是类目间比较吗?→ 柱状图(类目名长或类目多则横向条形图)。
  3. 是两个连续变量的关系吗?→ 散点图(点太密用 hexbin 或透明度)。
  4. 是单个连续变量的分布吗?→ 直方图 / 密度图 / 箱线图。
  5. 是多维分布(类目 × 连续)吗?→ 箱线图、小提琴图、热力图。
  6. 是占比构成吗?→ 堆叠柱状图或面积图(优于饼图)。
  7. 以上都不符合 → 先问自己「我真正想让读者回答什么问题」。

环境准备

安装命令

pip install matplotlib seaborn pandas numpy plotly
# 中文字体(Linux/macOS)
sudo apt-get install fonts-noto-cjk   # 或从系统字体目录拷贝 .ttf
pip install kaleido                    # Plotly 导出静态图片需要
要点Matplotlib 的后端(backend)决定图渲染到哪里:agg(存文件,服务器/无显示环境必备)、QtAgg/MacOSX(弹窗)。在服务器或 Jupyter 缺失图形环境时,先执行 import matplotlib; matplotlib.use("Agg") 再 pyplot。

Matplotlib 的架构:Figure 与 Axes

Matplotlib 模仿 MATLAB 的绘图接口,最核心的心智模型是两级结构:

对象类名含义常见方法
画布Figure整张图的容器,相当于一张纸add_subplot、subplots、suptitle、savefig、tight_layout
坐标系 / 绘图区Axes纸上的一块坐标系,所有图形元素都在这层plot、bar、scatter、set_xlabel、legend、set_title
具体元素Line2D / Patch / Text一条线、一个色块、一段文字set_color、set_label、annotate
常见坑Axes 这个复数名字极具误导性——它不是「坐标轴」,而是「一块绘图区」,里面包含 x 轴、y 轴、标题、图例等。fig.axes 拿到的是所有绘图区的列表;而坐标轴要通过 ax.xaxis / ax.yaxis 取。

两种绘图风格:pyplot 状态式 vs OO 式

import numpy as np
import matplotlib.pyplot as plt

# 风格一:pyplot 状态式(像 MATLAB),plt.plot() 自动创建/复用当前 Axes
x = np.arange(0, 10, 0.5)
plt.plot(x, np.sin(x), label="sin")
plt.plot(x, np.cos(x), label="cos")
plt.title("状态式绘图")
plt.legend()
plt.show()

# 风格二:面向对象(OO),显式拿 Figure 和 Axes —— 推荐、可组合、不污染全局状态
fig, ax = plt.subplots(figsize=(8, 4), dpi=120)
ax.plot(x, np.sin(x), label="sin")
ax.set_title("OO 式绘图")
ax.legend()
fig.tight_layout()          # 自动调整边距,防止标签被裁掉
plt.show()
技巧写工程代码时永远用 OO 式。pyplot 的隐式全局状态在循环里画图时会制造诡异 bug(比如第二次循环画在了第一张图上)。OO 式的写法还能天然对应「一个函数返回一张图」,方便复用。

一次创建多个子图

fig, axes = plt.subplots(2, 2, figsize=(10, 6))
fig.suptitle("四个子图的总标题", fontsize=16)   # 整张图的总标题
axes[0, 0].plot(np.random.rand(10), "o-")
axes[0, 0].set_title("左上")
axes[0, 1].bar(["a", "b", "c"], [3, 1, 2])
axes[1, 0].scatter(np.random.rand(20), np.random.rand(20))
axes[1, 1].hist(np.random.randn(1000), bins=30, edgecolor="black")
fig.tight_layout(rect=[0, 0, 1, 0.96])        # rect 给 suptitle 留出空间
plt.show()

# 按行循环的惯用写法
fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharex=True, sharey=True)
axes = axes.ravel()                            # 二维数组压成一维,便于遍历
for ax, col in zip(axes, ["r", "g", "b", "c", "m", "y"]):
    ax.plot(np.random.randn(50), color=col, alpha=0.6)
    ax.set_title(f"系列 {col}")
fig.tight_layout()
plt.show()

sharex=True / sharey=True 让多个子图共享坐标轴刻度,这在「同一指标的多组对比」中极其好用:读者一眼就能比出高低,而不需要在脑内换算刻度。

常用布局 API 对照

方法作用替代/推荐做法
plt.subplots(nrows, ncols, figsize, sharex, sharey)创建等大小的网格首选
fig.add_subplot(2, 1, 1)手动指定第几个格子不规则布局时使用
plt.subplot2grid((2, 2), (0, 0), colspan=2)跨格的不规则布局可用;现代写法推荐 GridSpec
fig.subplots_adjust(left=.1, right=.9, top=.9, bottom=.1)手动调边距自动布局失败时的兜底
fig.tight_layout()自动缩紧以免标签被裁几乎所有情况都应调用一次
fig.colorbar(mappable, ax=axes)挂色条热力图必备;传 ax=axes 可作用于多轴
fig.text(0.5, 0.01, "数据来源", ha="center")整图任意位置文字放脚注、数据来源

折线图:趋势与时间序列

import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta

# 造 30 天数据
dates = [datetime(2024, 1, 1) + timedelta(days=i) for i in range(30)]
rng = np.random.default_rng(42)
values = np.cumsum(rng.normal(0.5, 3, 30)) + 100      # 随机游走
values[10] = 118                                          # 人为制造一个峰

fig, ax = plt.subplots(figsize=(10, 4.5), dpi=120)

# 折线 + 数据点,zorder 控制叠放次序(越大越靠上)
ax.plot(dates, values,
        marker="o", markersize=4, linewidth=1.8,
        color="#2E86DE", label="日活跃用户",
        zorder=3)

# 填充区域(同一条线到 y=某值 之间),比纯线更有体积感
ax.fill_between(dates, values, np.mean(values) - 10,
                color="#2E86DE", alpha=0.15, label="波动区间")

# 均值参考线 + 峰值标注
ax.axhline(values.mean(), linestyle="--", color="gray", linewidth=1)
ax.annotate("峰值 118", xy=(dates[10], 118), xytext=(dates[4], 126),
            arrowprops=dict(arrowstyle="->", color="crimson"),
            color="crimson", fontsize=10,
            bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="crimson", alpha=0.9))

# 坐标轴与刻度
ax.set_title("30 天日活趋势", fontsize=14, pad=12)
ax.set_xlabel("日期")
ax.set_ylabel("活跃用户数")
ax.xaxis.set_major_locator(matplotlib.dates.AutoDateLocator())
ax.xaxis.set_major_formatter(matplotlib.dates.DateFormatter("%m-%d"))
fig.autofmt_xdate()                                        # 日期标签自动旋转防重叠
ax.grid(True, linestyle=":", alpha=0.5)
ax.legend(loc="upper left", frameon=False)
fig.tight_layout()
plt.show()

线条样式的关键参数

参数常用值说明
linestyle / ls"-" 实线、"--" 虚线、":" 点线、"-." 点划线用线型而非颜色区分序列,对黑白打印友好
linewidth / lw1~3论文/汇报建议 1.5~2.2
marker"o" 圆、"s" 方、"^" 三角、"D" 菱形数据点不多时加上,数据密集时去掉
markersize / ms3~6同上
alpha0~1透明度,画很多重叠序列时用 0.3~0.6
color / c"#RRGGBB"、"C0"~"C9"C0..C9 是默认色板循环,系列多时最省心
zorder默认 2~3网格线设 zorder=0 让它在数据下面

多序列折线 + 双 Y 轴

fig, ax1 = plt.subplots(figsize=(9, 4.5))
x = np.arange(12)
sales = np.array([120, 135, 150, 148, 170, 190, 185, 200, 215, 230, 228, 250])
rate = np.array([2.1, 2.4, 2.6, 2.5, 3.0, 3.4, 3.3, 3.8, 4.1, 4.5, 4.4, 5.0])

l1, = ax1.plot(x, sales, "o-", color="#2E86DE", label="销售额(万元)")
ax1.set_xlabel("月份")
ax1.set_ylabel("销售额(万元)", color="#2E86DE")
ax1.tick_params(axis="y", labelcolor="#2E86DE")

ax2 = ax1.twinx()                                       # 共享 X 轴的第二个 Y 轴
l2, = ax2.plot(x, rate, "s--", color="#E74C3C", label="增长率(%)")
ax2.set_ylabel("增长率(%)", color="#E74C3C")
ax2.tick_params(axis="y", labelcolor="#E74C3C")

ax1.legend(handles=[l1, l2], loc="upper left")          # 两个轴的图例要手动合并
fig.tight_layout()
plt.show()
常见坑双 Y 轴是最容易误导人的图形之一:两条曲线的相对位置关系完全取决于你把左轴和右轴的刻度范围调成什么样子,读者会本能地以为两条线有交点关系。除非业务上强需求,否则优先改用「上下两个子图」或统一单位。

面积图与堆叠面积图

fig, ax = plt.subplots(figsize=(9, 4))
quarters = ["Q1", "Q2", "Q3", "Q4"]
web = np.array([30, 38, 45, 52])
app = np.array([20, 22, 24, 30])
other = np.array([10, 9, 11, 8])

ax.stackplot(quarters, web, app, other,
             labels=["Web", "App", "其他"], colors=["#2E86DE", "#E74C3C", "#95A5A6"])
ax.set_title("各渠道营收构成(万元)")
ax.set_ylabel("营收(万元)")
ax.legend(loc="upper left")
ax.grid(True, alpha=0.3)
fig.tight_layout()
plt.show()
技巧stackplot 表达「总量 + 构成」。如果总量本身没有意义(比如不同月份人数总和毫无业务含义),就该用多条折线而不是堆叠面积。

柱状图、条形图与饼图

柱状图:最常用也最容易被用错

cats = ["北京", "上海", "广州", "深圳", "杭州", "成都"]
values = [1280, 1150, 980, 1350, 760, 620]

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))

# 竖向柱状图 + 单柱配色 + 数值标注
colors = plt.cm.Blues(np.linspace(0.4, 0.9, len(cats)))
bars = ax1.bar(cats, values, color=colors, width=0.6, edgecolor="white")
for bar, v in zip(bars, values):
    ax1.text(bar.get_x() + bar.get_width() / 2, v + 25, f"{v}",
             ha="center", va="bottom", fontsize=9)
ax1.set_ylim(0, max(values) * 1.15)          # 留出顶部标注空间
ax1.set_title("六城市销售额(万元)")
ax1.set_ylabel("销售额(万元)")
ax1.grid(axis="y", alpha=0.3)

# 横向条形图:类目名很长时更易读,且从上到下按值排序
order = np.argsort(values)
ax2.barh(np.array(cats)[order], np.array(values)[order], color="#27AE60")
ax2.set_title("横向条形图(已排序)")
ax2.set_xlabel("销售额(万元)")
ax2.grid(axis="x", alpha=0.3)

fig.tight_layout()
plt.show()

分组柱状图与堆叠柱状图

import numpy as np
months = ["1月", "2月", "3月", "4月"]
desktop = np.array([820, 932, 901, 934])
mobile = np.array([120, 132, 101, 134])

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))

# 分组(并排)
x = np.arange(len(months))
w = 0.35
b1 = ax1.bar(x - w / 2, desktop, w, label="桌面端", color="#2E86DE")
b2 = ax1.bar(x + w / 2, mobile, w, label="移动端", color="#E67E22")
ax1.set_xticks(x, months)                    # 新版写法:同时给位置和标签
ax1.bar_label(b1, fmt="%d", padding=2, fontsize=8)
ax1.bar_label(b2, fmt="%d", padding=2, fontsize=8)
ax1.set_title("分组柱状图:桌面 vs 移动")
ax1.legend()

# 堆叠
ax2.bar(months, desktop, label="桌面端", color="#2E86DE")
ax2.bar(months, mobile, bottom=desktop, label="移动端", color="#E67E22")
ax2.set_title("堆叠柱状图:总量与构成")
ax2.legend()

fig.tight_layout()
plt.show()
要点bar_label 是 Matplotlib 3.4+ 自带的柱顶自动标注方法,比手写 for 循环简洁。旧写法(3.4 之前)需要遍历 bar.get_height() 手动 text()。

饼图与环形图(donut)

labels = ["iOS", "Android", "Web", "其他"]
sizes = [45, 35, 15, 5]
colors = ["#2E86DE", "#E74C3C", "#27AE60", "#95A5A6"]
explode = (0.05, 0, 0, 0)      # 突出第一块

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 5))

wedges, texts, autotexts = ax1.pie(
    sizes, labels=labels, colors=colors, explode=explode,
    autopct="%1.1f%%",            # 显示百分比
    startangle=90,                # 从正上方开始,符合阅读习惯
    shadow=False,
    wedgeprops=dict(edgecolor="white", linewidth=2),
    textprops=dict(fontsize=10),
    pctdistance=0.75,
)
for t in autotexts:
    t.set_color("white")
    t.set_fontweight("bold")
ax1.set_title("流量来源占比")
ax1.axis("equal")                # 关键!保证饼是正圆而非椭圆

# 环形图 = wedgeprops 里加 width
wedges2, _ = ax2.pie(
    sizes, labels=labels, colors=colors, startangle=90,
    wedgeprops=dict(width=0.42, edgecolor="white"),
)
ax2.text(0, 0, f"总计\n{sum(sizes)}%", ha="center", va="center", fontsize=14)
ax2.set_title("环形图:中间可放汇总信息")

fig.tight_layout()
plt.show()
常见坑ax.axis("equal") 忘了写,饼会被拉成椭圆——因为 Matplotlib 默认保持数据比例而不是显示比例。另外饼图里的小块(<3%)标签必然重叠,实战中要么合并为「其他」,要么改用条形图。

直方图:分布的真相

import numpy as np
rng = np.random.default_rng(0)
data = np.concatenate([rng.normal(0, 1, 5000), rng.normal(3, 0.6, 800)])

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4))
ax1.hist(data, bins=40, color="#3498DB", edgecolor="white")
ax1.set_title("直方图 bins=40")
ax1.set_xlabel("数值")
ax1.set_ylabel("频数")

ax2.hist(data, bins=40, density=True, alpha=0.7, color="#9B59B6",
         edgecolor="white", label="直方图(归一化)")

# 手写两条理论密度曲线作为参照:N(0,1) 与 N(3, 0.6^2)
xs = np.linspace(data.min(), data.max(), 400)
theoretical = (0.3989 * np.exp(-xs ** 2 / 2)
               + 0.08 * np.exp(-(xs - 3) ** 2 / (2 * 0.36)))
ax2.plot(xs, theoretical, "k--", lw=1.5, label="理论密度")
ax2.set_xlabel("数值")
ax2.set_ylabel("概率密度")
ax2.set_title("直方图 vs 密度曲线")
ax2.legend()
fig.tight_layout()
plt.show()
参数含义建议
bins分箱数量用「平方根法则」bins = int(sqrt(n)) 或让 Sturges 公式给个初值;数据范围不同时用数组显式指定边界
range(左, 右) 值域需要多子图对比时固定它,否则各子图直方形状不可比
density=True归一化为概率密度(面积=1)和理论分布曲线叠加时必须开
histtype"bar"/"step"/"stepfilled"叠加多组数据时 "step" 只画轮廓不遮挡
常见坑直方图的 bins 取多少会直接改变结论。20 个 bin 和 200 个 bin 讲的是完全不同的故事。严谨的做法是至少画 3 个 bin 值对比,或者把 bin 宽度和 bin 数标在图上。

散点图、气泡图与 3D 图

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(7)
n = 300
x = rng.normal(0, 1, n)
y = 0.8 * x + rng.normal(0, 0.5, n)          # 正相关
size = rng.uniform(20, 200, n)                # 第三个维度 -> 气泡大小
color = y                                    # 第四个维度 -> 颜色

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.8))

# 基础散点:alpha 解决重叠遮挡问题
ax1.scatter(x, y, s=30, alpha=0.6, c="#2E86DE", edgecolors="white", linewidths=0.5)
z = np.polyfit(x, y, 1)
xs = np.linspace(x.min(), x.max(), 100)
ax1.plot(xs, np.polyval(z, xs), "r--", lw=2, label=f"拟合线 y={z[0]:.2f}x+{z[1]:.2f}")
ax1.set_title("基础散点 + 线性拟合")
ax1.legend()
ax1.grid(alpha=0.3)

# 气泡图:大小 + 颜色编码两维
sc = ax2.scatter(x, y, s=size, c=color, cmap="viridis", alpha=0.75,
                 edgecolors="white", linewidths=0.5)
ax2.set_title("气泡图:大小=规模,颜色=数值")
ax2.set_xlabel("x")
ax2.set_ylabel("y")
cbar = fig.colorbar(sc, ax=ax2)                # 必须配色条,否则颜色含义不明
cbar.set_label("指标值")
fig.tight_layout()
plt.show()

相关矩阵热力图(不依赖 seaborn 的原生写法)

features = rng.normal(0, 1, (200, 5))
names = ["长度", "宽度", "高度", "重量", "密度"]
corr = np.corrcoef(features, rowvar=False)

fig, ax = plt.subplots(figsize=(7, 6))
im = ax.imshow(corr, cmap="RdBu_r", vmin=-1, vmax=1)   # vmin/vmax 固定色标范围
ax.set_xticks(range(len(names)), names, rotation=45, ha="right")
ax.set_yticks(range(len(names)), names)

# 每个格子中央标数值(热力图不标数值等于残废)
for i in range(len(names)):
    for j in range(len(names)):
        ax.text(j, i, f"{corr[i, j]:.2f}", ha="center", va="center",
                color="white" if abs(corr[i, j]) > 0.55 else "black")

fig.colorbar(im, ax=ax, shrink=0.8, label="相关系数")
ax.set_title("特征相关性矩阵(Pearson)")
fig.tight_layout()
plt.show()

3D 曲面图

from mpl_toolkits.mplot3d import Axes3D      # noqa: F401 仅为触发注册
fig = plt.figure(figsize=(7, 5))
ax = fig.add_subplot(111, projection="3d")

X = np.linspace(-5, 5, 60)
Y = np.linspace(-5, 5, 60)
X, Y = np.meshgrid(X, Y)
Z = np.exp(-(X ** 2 + Y ** 2) / 8)            # 高斯曲面

surf = ax.plot_surface(X, Y, Z, cmap="viridis", alpha=0.85,
                       linewidth=0, antialiased=True)
ax.contour(X, Y, Z, zdir="z", offset=-0.1, cmap="viridis")
ax.set_xlabel("X")
ax.set_ylabel("Y")
ax.set_zlabel("Z")
ax.set_title("三维曲面")
fig.colorbar(surf, ax=ax, shrink=0.6)
plt.show()
常见坑3D 图在二维屏幕上是透视投影,会造成长度与面积的严重失真,而人眼恰恰最擅长判断长度和面积——这正是 Cleveland 论文里说的「最不该用的编码」。3D 柱状图尤其十恶不赦,3D 曲面图稍微可接受,3D 饼图是灾难。

样式、配色与主题

样式表(style sheet)机制

层级设置方式作用域典型用途
1plt.rcParams['lines.linewidth'] = 2当前进程全局脚本开头统一配置
2with plt.style.context('ggplot'):代码块内临时在报告里混用两种风格
3plt.style.use('seaborn-v0_8-whitegrid')当前进程全局切换项目级统一主题
4ax.set_xxx(...) 函数式单个 Axes精修某张图(优先级最高)
5plt.rc_context({...})上下文管理器测试里隔离配置
import matplotlib.pyplot as plt

# 查看可用风格
print(plt.style.available)
# ['default', 'classic', 'bmh', 'ggplot', 'seaborn-v0_8-darkgrid', ...]

plt.style.use("seaborn-v0_8-whitegrid")        # 全局切换主题

# 一份「专业感」配置文件(可放到 myproject/mplstyle.py)
STYLE = {
    "figure.dpi": 120,
    "figure.facecolor": "white",
    "savefig.dpi": 300,
    "savefig.bbox": "tight",
    "font.size": 11,
    "axes.titlesize": 14,
    "axes.labelsize": 11,
    "axes.grid": True,
    "axes.axisbelow": True,                   # 网格线在数据下面
    "grid.linestyle": ":",
    "grid.alpha": 0.4,
    "xtick.direction": "out",
    "ytick.direction": "out",
    "legend.frameon": False,
    "lines.linewidth": 1.8,
    "lines.markersize": 5,
    "axes.prop_cycle": plt.cycler(
        color=["#2E86DE", "#E74C3C", "#27AE60", "#F39C12", "#9B59B6", "#16A085"]
    ),
}
plt.rcParams.update(STYLE)
print("当前字号:", plt.rcParams["font.size"])

配色方案

fig, axes = plt.subplots(1, 3, figsize=(15, 3.6))

# 1) 定性数据:类别有区分度但无顺序,如「华东/华北/华南」
q_colors = ["#2E86DE", "#E74C3C", "#27AE60"]
axes[0].bar(["A", "B", "C"], [5, 7, 4], color=q_colors)
axes[0].set_title("定性色(Qualitative)")

# 2) 连续型顺序色:数值有大小之分,如「低/中/高」
seq_cmap = plt.cm.Blues
seq_colors = [seq_cmap(x) for x in np.linspace(0.3, 0.95, 3)]
axes[1].bar(["低", "中", "高"], [5, 7, 4], color=seq_colors)
axes[1].set_title("顺序色(Sequential)")

# 3) 双向发散色:有「中点」含义,如「亏损 <-0- 盈利」
div_cmap = plt.cm.RdYlGn
div_colors = [div_cmap(0.15), div_cmap(0.5), div_cmap(0.85)]
axes[2].bar(["亏损", "持平", "盈利"], [5, 7, 4], color=div_colors)
axes[2].set_title("发散色(Diverging)")

fig.tight_layout()
plt.show()

# 检验配色是否对色觉障碍友好
from matplotlib.colors import check_contrast      # 对比度检查
print(check_contrast((0.18, 0.53, 0.87), (1.0, 1.0, 1.0)))
常见坑用「彩虹色(jet/rainbow)」做连续数据是可视化界的经典错误:它既不是感知均匀的(中间黄和两端红对应的「数值差」视觉上一样大),又对色觉障碍者极不友好。连续数据一律用 viridis、magma、Blues、RdBu 这类感知均匀色带(perceptually uniform)。

网格、坐标轴与 spines

fig, ax = plt.subplots(figsize=(7, 4.5))

ax.plot(np.arange(10), np.random.rand(10) * 10, marker="o")
ax.grid(True, axis="y", linestyle="--", alpha=0.4)     # 只留水平网格更清爽
ax.grid(axis="x", visible=False)

# 只保留左、下两条轴线,去掉上、右边框(现代图表风格)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
ax.spines["left"].set_color("#BBBBBB")
ax.spines["bottom"].set_color("#BBBBBB")
ax.tick_params(colors="#555555", length=3)

ax.set_xlabel("X 轴标签", fontsize=11)
ax.set_ylabel("Y 轴标签", fontsize=11)
ax.set_xticks(np.arange(0, 10, 2))
ax.set_yticks(np.arange(0, 11, 2.5))
fig.tight_layout()
plt.show()

标注与标签:让图表会说话

一张好图的标题应当直接给出结论,而不是重复变量名。「销售额」是废话,「华东区销售额同比增长 32%,但客单价下降 8%」才是信息。

import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(9, 5))
x = np.arange(5)
y = np.array([120, 135, 150, 148, 170])

# 1) 标题:写结论而不是写变量名
ax.set_title("2024 Q2 营收环比连续三月正增长,但 4 月首次环比转负",
             fontsize=13, pad=14, loc="left")

# 2) 折线转折点标注:xy 是数据坐标,xytext 是显示坐标
ax.plot(x, y, "o-", color="#2E86DE")
ax.annotate("4 月环比 -1.3%",
            xy=(3, 148), xytext=(1.6, 163),
            arrowprops=dict(arrowstyle="-|>", color="#E74C3C", lw=1.5,
                            connectionstyle="arc3,rad=-0.2"),
            bbox=dict(boxstyle="round,pad=0.4", fc="#FFF3CD", ec="#E74C3C", lw=1),
            fontsize=10, color="#7B241C")

# 3) 参考区间:axhspan / axvspan
ax.axhspan(140, 160, color="#95A5A6", alpha=0.2)
ax.text(0.02, 150, "目标区间 140~160", transform=ax.get_yaxis_transform(),
        va="center", fontsize=9, color="#566573")

# 4) 箭头指向极值
i_max, i_min = int(np.argmax(y)), int(np.argmin(y))
ax.annotate(f"最高 {y[i_max]}", xy=(i_max, y[i_max]), xytext=(i_max - 1.6, y[i_max] + 9),
            arrowprops=dict(arrowstyle="->", color="green"), color="green")

# 5) 文本框补充口径说明
ax.text(0.5, -0.18, "数据来源:内部 BI 系统,统计口径为不含内部交易",
        transform=ax.transAxes, ha="center", fontsize=9, color="#888888")

# 6) 轴范围与标签
ax.set_xticks(x, ["1月", "2月", "3月", "4月", "5月"])
ax.set_ylim(100, 185)
ax.set_xlabel("月份")
ax.set_ylabel("营收(百万元)")
ax.legend(["营收"], loc="lower right", frameon=False)
fig.tight_layout()
plt.show()

标注 API 速查

API用途关键参数
ax.text(x, y, s)放一段文字transform=ax.transAxes 用 0~1 相对坐标定位
ax.annotate(s, xy, xytext)带箭头的标注arrowprops 控制箭头样式
ax.bar_label(bars)柱顶标数值fmt="%.1f"、padding
ax.legend()图例loc、ncol、frameon、title
ax.set_title(t, loc="left")子图标题loc 可选 left/center/right
fig.suptitle(t)整图大标题需配合 tight_layout(rect=...)
ax.axhline / axvline / axhspan / axvspan参考线与参考带color、ls、alpha、xmin/xmax

多子图的共享图例

fig, axes = plt.subplots(2, 2, figsize=(10, 7))
for ax in axes.ravel():
    ax.plot([1, 2, 3], label="系列 A")
    ax.plot([3, 2, 1], label="系列 B")
    ax.set_title(f"子图 {ax.get_title() or ''}", fontsize=10)

# 先在第一个 ax 上建图例,再取它的 handles 给整张图用
handles, labels = axes[0, 0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper center", ncol=2, bbox_to_anchor=(0.5, 1.0))
fig.suptitle("全局共享图例示例", y=0.98)
fig.tight_layout(rect=[0, 0, 1, 0.93])
plt.show()

中文字体与负号显示

中文环境下 Matplotlib 有两个必踩的坑:

  1. 中文显示为方框豆腐块——因为默认字体 DejaVu Sans 没有 CJK 字形。
  2. 负号显示为方框——因为负号 U+2212 不在默认字体里,Matplotlib 官方 FAQ 给的解法是把 axes.unicode_minus 关掉。
import matplotlib
matplotlib.use("Agg")                      # 服务器环境务必放最前
import matplotlib.pyplot as plt
from matplotlib import font_manager

# 1) 查看系统里有哪些字体(含字体文件路径)
available = {f.name for f in font_manager.fontManager.ttflist}
print("是否含中文字体:", {"SimHei", "Microsoft YaHei", "Noto Sans CJK SC", "WenQuanYi Micro Hei"} & available)

# 2) 方案 A:按字体「名称」设置(最常用)
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "Noto Sans CJK SC"]
plt.rcParams["axes.unicode_minus"] = False       # 修复负号方框

# 3) 方案 B:按字体「文件路径」加载私有字体(推荐给服务器/CI,最可控)
font_path = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
try:
    font_manager.fontManager.addfont(font_path)
    prop = font_manager.FontProperties(fname=font_path)
    print("已加载字体:", prop.get_name())
    plt.rcParams["font.family"] = prop.get_name()
except FileNotFoundError:
    print("字体文件不存在,回退到系统字体名方案")
plt.rcParams["axes.unicode_minus"] = False

# 4) 局部指定字体(只给某一个文本对象)
fig, ax = plt.subplots(figsize=(6, 3))
bars = ax.bar(["一月", "二月", "三月"], [3, 5, 2])
ax.bar_label(bars, labels=["+12%", "-8%", "+3%"],       # 含负号的中文标注
             fontsize=10, color="#333333")
fig.tight_layout()
fig.savefig("chinese_font_demo.png", dpi=150)
print("saved")

下载并使用中文字体的完整脚本

# -*- coding: utf-8 -*-
"""把中文字体下载到项目内 fonts/ 目录,避免依赖系统字体。"""
import os
import urllib.request
from matplotlib import font_manager

FONT_DIR = os.path.join(os.path.dirname(__file__), "fonts")
FONT_FILE = os.path.join(FONT_DIR, "NotoSansSC-Regular.otf")
URL = "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF/SimplifiedChinese/NotoSansCJKsc-Regular.otf"

def setup_chinese_font() -> str:
    os.makedirs(FONT_DIR, exist_ok=True)
    if not os.path.exists(FONT_FILE):
        urllib.request.urlretrieve(URL, FONT_FILE)          # 首次联网下载
    font_manager.fontManager.addfont(FONT_FILE)
    name = font_manager.FontProperties(fname=FONT_FILE).get_name()
    import matplotlib.pyplot as plt
    plt.rcParams["font.family"] = name
    plt.rcParams["axes.unicode_minus"] = False
    return name

if __name__ == "__main__":
    print("当前字体:", setup_chinese_font())

字体相关排错

症状原因解决
中文全是方框 □字体不含 CJK 字形设置 font.sans-serif 为已安装的 CJK 字体名,或 addfont() 加载 ttf
负号变方框默认字体缺 U+2212plt.rcParams["axes.unicode_minus"] = False
设置了但无效在 pyplot 导入前设置无效 / 字体名拼写错设置代码放在所有 import 之后、绘图之前;用 font_manager.findfont() 验证
图上中文正常,保存 PNG 后变方框矢量后端(PDF/SVG)字体未嵌入PNG 无此问题;导出 PDF 前设 pdf.fonttype = 42 或用 svg.fonttype = "none"
标签被裁掉没调布局fig.tight_layout() 或 savefig(bbox_inches="tight")
技巧用 font_manager.fontManager.ttflist 列出全部已加载字体及其路径,是排查字体问题最快的手段:for f in font_manager.fontManager.ttflist: print(f.name, f.fname)。

Seaborn:统计图表的瑞士军刀

Seaborn 是构建在 Matplotlib 之上的统计绘图库,核心理念是「面向数据集(data-frame oriented)」和「语义化映射(semantic mapping)」:你不画具体的线,你声明「颜色代表什么变量」「位置代表什么变量」,剩下的它来推断。

import numpy as np
import pandas as pd
import seaborn as sns

sns.set_theme(style="whitegrid", context="talk", palette="deep")
# style  : whitegrid / darkgrid / ticks / white / dark
# context: talk / paper / notebook / poster(控制整体字号)
# palette: deep / muted / bright / pastel / dark / colorblind

# 造一份贴合业务的数据集
rng = np.random.default_rng(2024)
df = pd.DataFrame({
    "城市": rng.choice(["北京", "上海", "广州", "深圳"], 400),
    "渠道": rng.choice(["线上", "线下", "代理"], 400),
    "销售额": rng.gamma(shape=2.0, scale=500, size=400).round(1),
    "满意度": rng.normal(7.5, 1.4, 400).clip(1, 10),
    "满意度_整数": rng.integers(6, 10, 400),
    "月份": rng.choice(["1月", "2月", "3月", "4月"], 400),
})
df["增长额"] = df["销售额"] * 0.15
print(df.head())

Seaborn 核心函数速查

函数等价 Matplotlib用途
sns.relplotscatter/line + FacetGrid关系图,自动按 hue/size/style 分面
sns.catplotbar/box/violin类别图,kind 可切换十几种
sns.displothist/kde分布图
sns.lmplotpolyfit + scatter回归散点(含置信带)
sns.heatmapimshow + 文字热力图,自带数值标注
sns.pairplot无多变量两两散点矩阵
sns.jointplotscatter + marginal两个变量的联合分布
sns.kdeplot—核密度估计曲线
sns.boxplot / violinplotboxplot分组分布对比
sns.stripplot / swarmplotscatter在分类轴上展示原始点

箱线图 + 小提琴图 + 原始点(分布三件套)

fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(16, 4.6))

sns.boxplot(data=df, x="城市", y="销售额", hue="渠道", ax=ax1, palette="Set2")
ax1.set_title("箱线图:五数概括")
ax1.set_xticklabels(ax1.get_xticklabels(), rotation=20)

sns.violinplot(data=df, x="城市", y="销售额", hue="渠道",
               palette="Set2", inner="quart", cut=0, ax=ax2, split=False)
ax2.set_title("小提琴图:核密度宽度")

sns.stripplot(data=df, x="城市", y="销售额", hue="渠道",
              jitter=True, alpha=0.5, size=3, palette="Set2", ax=ax3)
sns.boxplot(data=df, x="城市", y="销售额", hue="渠道", palette="Set2",
            showfliers=False, ax=ax3, width=0.5)
ax3.set_title("蜂群图 + 箱线叠加")
for ax in (ax1, ax2, ax3):
    ax.tick_params(axis="x", rotation=20)
fig.tight_layout()
plt.show()
要点箱线图的「箱」是第一四分位数到第三四分位数(中间 50%),箱中线是中位数,须线延伸到 1.5 倍 IQR 内的最远值,须外的点是离群点(outlier)。小提琴图把核密度曲线左右镜像,宽度代表该区间的数据密度,信息量比箱线图更大但对小样本不稳。工程建议:样本量小于 30 时,用 stripplot 把原始点画出来,避免「用中位数代表一切」。

关系图与回归

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.6))

# hue 分组 + size 映射第三个变量 + style 分线型
sns.scatterplot(data=df, x="销售额", y="满意度_整数", hue="渠道",
                size="增长额", sizes=(30, 300), style="渠道",
                palette="Dark2", alpha=0.7, ax=ax1)
ax1.set_title("scatterplot:颜色/大小/形状三重映射")

# lmplot:线性回归 + 95% 置信带
sns.regplot(data=df, x="销售额", y="满意度_整数",
            scatter_kws={"alpha": 0.4, "s": 20}, line_kws={"color": "red"},
            ax=ax2)
sns.kdeplot(data=df, x="销售额", hue="渠道", fill=True, alpha=0.25,
            warn_singular=False, ax=ax2)
ax2.set_title("regplot + kdeplot")
fig.tight_layout()
plt.show()

# 分类时间序列:sns.lineplot 会自动对重复 x 求均值并画置信区间
fig, ax = plt.subplots(figsize=(9, 4.5))
sns.lineplot(data=df, x="月份", y="销售额", hue="城市", marker="o", ax=ax)
ax.set_title("按城市分组的月度均值趋势(95% CI)")
fig.tight_layout()
plt.show()
常见坑Seaborn 的 lineplot 遇到重复的 x 值会自动聚合(默认取均值)并画出置信区间,而不是像 Matplotlib plot 那样把所有点连起来。如果你需要连原始点,请改用 sns.lineplot(estimator=None)。这个默认行为是很多人「图上怎么点变少了」的根因。

热力图与相关矩阵

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5),
                                gridspec_kw={"width_ratios": [1, 1.15]})

num_df = df[["销售额", "满意度", "满意度_整数", "增长额"]]
corr = num_df.corr()

mask = np.triu(np.ones_like(corr, dtype=bool))          # 遮住上三角,减少冗余
sns.heatmap(corr, mask=mask, annot=True, fmt=".2f",
            cmap="coolwarm", center=0, vmin=-1, vmax=1,
            square=True, linewidths=0.5, ax=ax1)
ax1.set_title("数值型相关矩阵(上三角已隐藏)")

# 交叉表热力图:两个类目变量的频次
ct = pd.crosstab(df["城市"], df["渠道"])
sns.heatmap(ct, annot=True, fmt="d", cmap="YlGnBu", ax=ax2,
            cbar_kws={"label": "记录数"})
ax2.set_title("城市 × 渠道 记录数交叉表")
fig.tight_layout()
plt.show()
技巧相关系数矩阵用 center=0 + 发散色带 + vmin=-1, vmax=1,让「0(不相关)」正好落在中性色上,读者一眼能看出正负方向。相关系数只测线性关系,可以用 spearman 看单调关系:df.corr(method="spearman")。

pairplot 与 jointplot

g = sns.pairplot(df, vars=["销售额", "满意度", "增长额"],
                 hue="渠道", palette="Set1", diag_kind="kde",
                 corner=True, height=3.2, plot_kws={"alpha": 0.6, "s": 20})
g.fig.suptitle("成对关系矩阵(仅下三角)", y=1.02)
g.map_upper(sns.kdeplot, fill=True, alpha=0.3, warn_singular=False)
plt.show()

g2 = sns.jointplot(data=df, x="销售额", y="满意度_整数", hue="渠道",
                   kind="reg", height=5.5,
                   marginal_kws=dict(bins=25, alpha=0.4),
                   joint_kws=dict(alpha=0.5))
g2.fig.suptitle("联合图:回归趋势 + 边缘分布", y=1.0)
plt.show()

facet 分面网格

g = sns.relplot(data=df, x="销售额", y="满意度_整数",
                 hue="渠道", col="城市", col_wrap=2,          # 每行 2 个分面
                 kind="scatter", height=3.2, aspect=1.2,
                 palette="Set2", facet_kws={"sharey": True})
g.set_axis_labels("销售额(元)", "满意度")
g.set_titles("{col_name}")
g.add_legend(loc="lower right")
g.figure.tight_layout()
plt.show()

与 Pandas 无缝联动

import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

df = pd.read_csv("sales.csv", parse_dates=["日期"])      # 重要:先解析成 datetime

# 1) 直接调用绘图方法(会自动带索引到 x 轴,列名到 legend)
ax = df["销售额"].plot(figsize=(10, 4), title="销售额走势", grid=True)
ax.set_ylabel("销售额(万元)")
ax.figure.tight_layout()

# 2) 透视 + 绘图:把 DataFrame 直接变图
pivot = df.pivot_table(index="日期", columns="城市",
                       values="销售额", aggfunc="sum").sort_index()
ax = pivot.plot(figsize=(11, 4.5), marker="o", linewidth=1.6)
ax.set_title("各城市销售额日趋势")
ax.set_ylabel("销售额(万元)")
ax.legend(title="城市", ncol=len(pivot.columns))
ax.figure.tight_layout()

# 3) 分布直方图:kind 支持 count / bar / box / kde / area / violin / scatter / hex
ax = df["销售额"].plot.hist(bins=30, figsize=(8, 4), density=True, alpha=0.7)
df["销售额"].plot.kde(ax=ax, color="red", linewidth=2)
ax.set_title("销售额分布(直方图 + 核密度)")

# 4) 散点矩阵
pd.plotting.scatter_matrix(df[["销售额", "满意度"]], figsize=(7, 7),
                           diagonal="kde", alpha=0.5, color="steelblue")

# 5) 箱线图按类目分组
ax = df.boxplot(column="销售额", by="城市", figsize=(9, 4.5))
ax.set_title("")                    # 去掉 Pandas 自动生成的重复标题
plt.suptitle("各城市销售额分布", y=1.02)
plt.xticks(rotation=20)

# 6) 自定义:算好统计量再画,手控力最强
agg = df.groupby("城市")["销售额"].agg(["mean", "std", "count"]).sort_values("mean")
fig, ax = plt.subplots(figsize=(8, 4.5))
bars = ax.bar(agg.index, agg["mean"], yerr=agg["std"], capsize=4,
              color="#2E86DE", alpha=0.85)
ax.bar_label(bars, fmt="%.0f", padding=3)
ax.set_ylabel("平均销售额(万元)")
ax.set_title("各城市平均销售额(误差棒 = 标准差)")
fig.tight_layout()
plt.show()
要点DataFrame.plot() 只是 Matplotlib 的语法糖,参数直通底层(kind、x、y、hue、stacked、figsize、title),返回值仍是 Axes 对象,所以可以继续 ax.set_*、ax.bar_label()。工程上推荐「先用 Pandas 聚合成一张小汇总表,再画图」,比让 Matplotlib 处理几十万原始行快得多,也更容易控制图面整洁。

Plotly:交互式可视化与 Dash 仪表板

Plotly 的卖点是交互 + 浏览器渲染(WebGL/SVG)+ 零成本嵌网页。它有三大件:

库定位典型用途
plotly.graph_objects低层 API(类似 Matplotlib 的显式风格)精细控制每一个属性
plotly.express(px)高层 API,一行出图快速探索、Notebook 展示
dashPython 框架,把图变成 Web 应用仪表板、管理后台、交互报表

Plotly Express:五行代码出一张交互图

import plotly.express as px

fig = px.scatter(df, x="销售额", y="满意度_整数", color="渠道",
                 size="增长额", symbol="城市",
                 hover_name="城市", hover_data={"月份": True, "满意度": ":.2f"},
                 facet_col="月份", facet_col_wrap=2,
                 trendline="ols",                       # 需要 statsmodels
                 title="销售额与满意度的关系",
                 labels={"销售额": "销售额(元)", "满意度_整数": "满意度"})
fig.update_layout(template="plotly_white", legend_title="渠道", height=700)
fig.update_xaxes(title_text="销售额(元)", row=1, col=1)
fig.show()

# 常用图表的 px 速记
px.line(df, x="日期", y="销售额", color="城市")            # 折线
px.bar(df, x="城市", y="销售额", color="渠道", barmode="stack")  # 柱状
px.pie(df, names="渠道", values="销售额", hole=0.4)        # 环形图
px.box(df, x="城市", y="满意度", color="渠道")              # 箱线图
px.violin(df, x="城市", y="销售额", box=True, points="all")# 小提琴图
px.histogram(df, x="销售额", color="渠道", marginal="rug", nbins=40)
px.density_contour(df, x="销售额", y="满意度", color="渠道")# 等高线
px.imshow(corr, text_auto=".2f", color_continuous_scale="RdBu_r",
          zmin=-1, zmax=1, labels=dict(x="变量", y="变量"))   # 热力图
px.parallel_coordinates(df[["销售额", "满意度", "增长额"]])   # 平行坐标

graph_objects:精细控制

import plotly.graph_objects as go

fig = go.Figure()

# 两条线 + 自定义 hover + 填充
fig.add_trace(go.Scatter(
    x=[1, 2, 3, 4], y=[10, 15, 13, 20],
    mode="lines+markers", name="线上渠道",
    line=dict(color="#2E86DE", width=3, shape="spline", smoothing=1.0),
    marker=dict(size=10, symbol="circle", color="#2E86DE",
                line=dict(width=2, color="white")),
    fill="tozeroy", fillcolor="rgba(46,134,222,0.15)",
    hovertemplate="第 %{x} 周<br>销售额 %{y} 万元<extra></extra>",
))
fig.add_trace(go.Bar(
    x=[1, 2, 3, 4], y=[8, 12, 9, 14], name="线下渠道",
    marker_color="#E74C3C", text=[8, 12, 9, 14], textposition="outside",
))

fig.update_layout(
    title=dict(text="渠道周销售额对比", x=0.02, xanchor="left"),
    xaxis_title="周次", yaxis_title="销售额(万元)",
    template="plotly_white", hovermode="x unified",
    legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0),
    width=900, height=450,
)
fig.update_layout(barmode="group", bargap=0.25)
fig.show()

# 输出为独立 HTML 文件(自包含 JS,无需联网)—— 交付给不懂 Python 的同事
fig.write_html("chart.html", include_plotlyjs="cdn", full_html=True)
# 需要离线打开就把 include_plotlyjs 改为 True(文件约 4MB)
fig.write_html("chart_offline.html", include_plotlyjs=True)

# 导出静态图需要 kaleido:pip install -U kaleido
fig.write_image("chart.png", width=1200, height=600, scale=2)
fig.write_image("chart.pdf")

Plotly 交互配置速查

属性作用示例值
hovertemplate自定义悬浮提示"x=%{x}<br>y=%{y:.2f}<extra></extra>"
hovermode提示联动模式"x unified"(顶部统一条)、"closest"
updatemenus按钮切换图型折线/柱状切换
rangeslider底部时间滑块dict(visible=True)
animation_frame逐帧动画px.bar(df, x=..., y=..., animation_frame="月份")
barmode柱状组合方式group / stack / relative
config前端行为开关{"displaylogo": False, "toImageButtonOptions": {...}}
# 关闭右上角 Plotly logo、去掉模式栏按钮、响应式
config = {
    "displaylogo": False,
    "displayModeBar": "hover",                 # 鼠标移上去才显示工具条
    "responsive": True,
    "scrollZoom": True,
    "toImageButtonOptions": {"format": "png", "filename": "sales", "scale": 2},
    "locale": "zh-cn",
}
fig.show(config=config)

# 注入到 HTML 模板中
html = fig.to_html(include_plotlyjs="cdn", full_html=False, config=config)
with open("report_snippet.html", "w", encoding="utf-8") as f:
    f.write(f"<div class='chart-box'>{html}</div>")
print("HTML 片段已写出")

Dash:把图表做成网页仪表板

# app.py —— 运行:python app.py  然后浏览器打开 http://127.0.0.1:8050
from dash import Dash, dcc, html, Input, Output, callback
import pandas as pd
import plotly.express as px

app = Dash(__name__)
app.title = "销售分析仪表板"

df = pd.read_csv("sales.csv")
CITY_OPTIONS = [{"label": c, "value": c} for c in sorted(df["城市"].unique())]
CHANNEL_OPTIONS = [{"label": "全部", "value": "ALL"}] + [
    {"label": c, "value": c} for c in sorted(df["渠道"].unique())
]

app.layout = html.Div(
    style={"fontFamily": "sans-serif", "padding": "24px", "maxWidth": "1200px", "margin": "auto"},
    children=[
        html.H1("销售分析仪表板", style={"color": "#2E86DE"}),
        html.Div([
            html.Div([html.Label("城市"), dcc.Dropdown(id="city", options=CITY_OPTIONS,
                                                       value="ALL", clearable=True)],
                     style={"flex": "1", "marginRight": "12px"}),
            html.Div([html.Label("渠道"), dcc.Dropdown(id="channel", options=CHANNEL_OPTIONS,
                                                       value="ALL", clearable=True)],
                     style={"flex": "1", "marginRight": "12px"}),
            html.Div([html.Label("指标"), dcc.Dropdown(
                id="metric",
                options=[{"label": "销售额", "value": "销售额"},
                         {"label": "满意度", "value": "满意度"}],
                value="销售额", clearable=False)], style={"flex": "1"}),
        ], style={"display": "flex", "gap": "8px", "marginBottom": "16px"}),
        html.Div(id="kpi-row", style={"display": "flex", "gap": "16px", "marginBottom": "16px"}),
        dcc.Graph(id="trend", style={"height": "380px"}),
        dcc.Graph(id="dist", style={"height": "380px"}),
        dcc.Graph(id="share"),
    ],
)

@callback(
    Output("kpi-row", "children"),
    Output("trend", "figure"),
    Output("dist", "figure"),
    Output("share", "figure"),
    Input("city", "value"),
    Input("channel", "value"),
    Input("metric", "value"),
)
def update(city, channel, metric):
    d = df.copy()
    if city and city != "ALL":
        d = d[d["城市"] == city]
    if channel and channel != "ALL":
        d = d[d["渠道"] == channel]

    total = round(d["销售额"].sum(), 1)
    avg_sat = round(d["满意度"].mean(), 2) if len(d) else 0
    cnt = len(d)

    def card(label, value):
        return html.Div([
            html.Div(label, style={"fontSize": "13px", "color": "#777"}),
            html.Div(value, style={"fontSize": "26px", "fontWeight": "bold", "color": "#2E86DE"}),
        ], style={"flex": "1", "padding": "14px", "border": "1px solid #e6e6e6",
                  "borderRadius": "8px", "boxShadow": "0 1px 4px rgba(0,0,0,.06)"})

    kpis = [card("总销售额(元)", f"{total:,.0f}"), card("平均满意度", avg_sat),
            card("记录数", cnt)]

    trend = px.line(d, x="日期" if "日期" in d.columns else "月份", y=metric,
                    color="渠道" if "渠道" in d.columns else None,
                    markers=True, template="plotly_white", title=f"{metric} 趋势")
    dist = px.histogram(d, x=metric, color="渠道" if "渠道" in d.columns else None,
                        marginal="box", nbins=35, template="plotly_white",
                        title=f"{metric} 分布")
    share = px.pie(d, names="渠道", values="销售额", hole=0.45, template="plotly_white",
                   title="渠道占比")
    return kpis, trend, dist, share


if __name__ == "__main__":
    app.run(debug=True, port=8050)      # 生产用 gunicorn + waitress
# 生产部署:gunicorn -b 0.0.0.0:8050 app:server
#            其中 server = app.server
常见坑Dash 的 debug=True 会开热重载并暴露调试信息,只能用于本地开发。生产环境用 waitress(Windows 友好)或 gunicorn(Linux),并设置 server = app.server 供 WSGI 服务器加载。此外 Dash 默认允许任意回调返回值进入 HTML,动态拼接字符串时要做转义。

Matplotlib / Seaborn / Plotly 选型

维度MatplotlibSeabornPlotlyDash
定位底层绘图库(事实标准)统计图封装层交互式图表库交互式 Web 应用框架
输出PNG/SVG/PDF同左HTML / PNG / PDF网页应用
学习成本高(API 庞大)低中中
定制自由度★★★★★★★★(可回落到 Matplotlib)★★★★(受 JSON schema 限制)★★★★
交互能力极弱无强强
适合场景论文插图、精确排版、批量出图探索分析、统计分布图汇报演示、嵌入网页业务仪表板
性能(大数据)需降采样需降采样WebGL 可扛十万点同左
技巧实操建议:探索阶段用 Seaborn 快速看数据,结论阶段用 Matplotlib 精修排版,交付给非技术方用 Plotly / Dash 做交互。三者共享同一份 DataFrame 数据,管线里可以串联:df → sns 出草图 → plt 定稿 → px 出网页版。

保存、导出与组合发布

import matplotlib.pyplot as plt

fig, ax = plt.subplots(figsize=(8, 4.5), dpi=100)

# 1) 保存前务必先调布局,否则标签会被裁掉
fig.tight_layout()

fig.savefig("out/chart.png", dpi=300, bbox_inches="tight",
            facecolor="white", transparent=False)
fig.savefig("out/chart.pdf", bbox_inches="tight")        # 矢量,论文首选
fig.savefig("out/chart.svg", bbox_inches="tight")        # 网页无损缩放

# 2) 拼接多张子图到一个文件
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
for i, ax in enumerate(axes.ravel()):
    ax.plot(np.random.randn(50).cumsum())
    ax.set_title(f"面板 {i + 1}")
fig.suptitle("年度四季度指标总览", fontsize=16)
fig.tight_layout(rect=[0, 0, 1, 0.95])
fig.savefig("out/dashboard_2x2.png", dpi=200, bbox_inches="tight")

# 3) 从保存的图读回(可用于二次合成)
img = plt.imread("out/chart.png")
fig2, ax2 = plt.subplots(figsize=(10, 6))
ax2.imshow(img)
ax2.axis("off")
fig2.savefig("out/embedded.png", dpi=150, bbox_inches="tight")

# 4) 导出交互 HTML 与静态图共存(发给别人看)
plotly_fig.write_html("out/interactive.html", include_plotlyjs="cdn")

# 5) DPI 该选多少
#    网页/屏幕: 100~150 dpi   论文印刷: 300~600 dpi   巨幅海报: 150~300 dpi
import os
for name in os.listdir("out"):
    print(f"{name:24s} {os.path.getsize(os.path.join('out', name)) / 1024:8.1f} KB")

批量出图工程模板

"""把 DataFrame 里每个城市都画一张趋势图,输出到 output/ 目录。"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")                                  # 无显示环境必须
import matplotlib.pyplot as plt

OUT = Path("output")
OUT.mkdir(exist_ok=True)
plt.rcParams.update({
    "font.sans-serif": ["Noto Sans CJK SC", "SimHei"],
    "axes.unicode_minus": False,
    "figure.dpi": 110,
    "savefig.dpi": 200,
    "savefig.bbox": "tight",
})

def plot_city_trend(df, city, out_dir: Path = OUT) -> Path:
    sub = df[df["城市"] == city].sort_values("日期")
    fig, ax = plt.subplots(figsize=(9, 4))
    ax.plot(sub["日期"], sub["销售额"], marker="o", linewidth=1.6,
            label=city, color="#2E86DE")
    ax.set_title(f"{city} 销售额走势")
    ax.set_xlabel("日期")
    ax.set_ylabel("销售额(万元)")
    ax.grid(alpha=0.3)
    ax.legend()
    fig.autofmt_xdate()
    fig.tight_layout()
    path = out_dir / f"trend_{city}.png"
    fig.savefig(path)
    plt.close(fig)                                   # 重要:释放内存,否则批量跑会 OOM
    return path

if __name__ == "__main__":
    import pandas as pd
    df = pd.read_csv("sales.csv", parse_dates=["日期"])
    for city in df["城市"].unique():
        print("已生成:", plot_city_trend(df, city))
常见坑批量画图时忘记 plt.close(fig),几百张图后内存会涨到几个 GB 直到进程被杀。另一个坑是忘记 matplotlib.use("Agg"),在没有图形界面的服务器或 Docker 里直接崩在 plt.show() 或 savefig。

本章小结

  • 图形选型先于画图:人眼从「位置/长度」读数最准。趋势用折线、类目比较用柱状、关系用散点、分布用直方/箱线/小提琴、构成用堆叠图;饼图只在类目 ≤5 且关心占比时使用。
  • Matplotlib 心智模型:Figure(画布)→ Axes(绘图区,不是坐标轴)→ 元素。工程代码统一用 OO 式 fig, ax = plt.subplots()。
  • 常用布局:plt.subplots(nrows, ncols, sharex, sharey)、axes.ravel() 遍历、fig.suptitle + tight_layout(rect=...)、fig.colorbar(m, ax=axes)。
  • 折线图关键参数:marker、linestyle、alpha、zorder、fill_between;双 Y 轴要警惕视觉误导。
  • 柱状图:ax.bar(..., width=, color=, edgecolor=);类目名长或多时改 barh;数值标注用 bar_label();饼图务必 axis("equal")。
  • 直方图 bins 直接决定结论,可用平方根法则给初值,多子图对比时务必固定 range。
  • 样式用 plt.rcParams.update() 或样式表(style/context)集中管理;连续数据只用感知均匀色带(viridis/RdBu),禁用 jet/rainbow。
  • 标注要点:标题写结论不写变量名;annotate 画箭头;axhspan 画参考带;热力图不标数值等于废图;transform=ax.transAxes 做相对定位。
  • 中文:font.sans-serif 设 CJK 字体名或 addfont() 加载字体文件;负号方框用 axes.unicode_minus = False;服务端加 matplotlib.use("Agg")。
  • Seaborn是「语义映射 + 数据集导向」:relplot/catplot/displot/lmplot/heatmap/pairplot/jointplot;注意 lineplot 默认对重复 x 求均值并加置信区间。
  • 与 Pandas 联动:df.plot(kind=) 是语法糖;推荐先 groupby 聚合成小表再画,控制力与性能都更好。
  • Plotly:px 一行出图、go 精细控制;write_html 产出可交付的交互文件;Dash 把图升级为带筛选器的 Web 仪表板。
  • 工程规范:先 tight_layout() 再 savefig;网页 100~150 dpi、论文 300 dpi 以上;批量出图用 Agg 后端并及时 plt.close(fig)。

练习题

  1. 基础折线图与坐标轴:生成 2024 全年 365 天的随机温度序列(含正弦季节项 + 噪声),画出折线图,要求:① 只画 12 个月的首日;② 最高点用 annotate 标注;③ 7 月均值画一条红色虚线参考线;④ 横轴标签为中文月份且旋转 30°;⑤ 保存为 300 dpi PNG。
    参考答案要点:days = pd.date_range("2024-01-01", periods=365);temp = 18 + 10*np.sin(2*np.pi*(day_of_year-100)/365) + rng.normal(0,2,365);ax.plot(days[::31], temp[::31], "o-");ax.axhline(july_mean, color="red", ls="--", label="7月均值");ax.annotate("最高 %.1f℃" % temp.max(), xy=(days[temp.argmax()], temp.max()), xytext=(..., ...), arrowprops=dict(arrowstyle="->"));fig.autofmt_xdate() 或 plt.setp(ax.get_xticklabels(), rotation=30);最后 fig.tight_layout(); fig.savefig("t.png", dpi=300)。
  2. 中文字体与负号排错:写一个 setup_font() 函数,依次尝试「按字体名设置」和「按字体文件 addfont()」两种方案,并在标题中同时包含中文和负数(如「降温 -8.3℃」)。故意先不设 axes.unicode_minus=False 跑一次,对比差异。
    参考答案要点:from matplotlib import font_manager;先 names = {f.name for f in font_manager.fontManager.ttflist} 打印确认系统字体;font_manager.fontManager.addfont(path) 后用 FontProperties(fname=path).get_name() 拿名字;plt.rcParams["font.sans-serif"]=[name];plt.rcParams["axes.unicode_minus"]=False;断言 matplotlib.rcParams["font.sans-serif"][0] 非默认值。若无 GUI 环境,第一行加 matplotlib.use("Agg")。
  3. 分组柱状图 + 堆叠柱状图:构造 4 个城市 × 3 个渠道 × 6 个月的数据,画出:① 并排分组柱状图(带柱顶数值);② 堆叠柱状图(总量 + 构成);③ 把两张图放在同一 Figure 的两个子图里,共用一个 legend。
    参考答案要点:fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 4.5), sharey=True);分组用 w=0.25,循环 for i, ch in enumerate(channels): ax1.bar(x + (i-1)*w, vals, w, label=ch);柱顶标注 for b in bars: ax1.text(b.get_x()+b.get_width()/2, b.get_height()+8, f"{b.get_height():.0f}", ha="center");堆叠第二组要 bottom=prev 累加;共享图例:h,l = ax1.get_legend_handles_labels(); fig.legend(h, l, loc="upper center", ncol=3)。
  4. 子图网格与布局调优:用 plt.subplots(3, 4, sharex=True, sharey=True) 创建 12 个子图,依次画 12 个月份的日销售额。目标:让图例只出现一次(整图级)、总标题在最上方且不与子图标题重叠、导出后坐标标签不被裁切。
    参考答案要点:axes = axes.ravel() 后循环;fig.suptitle("...", y=0.995);fig.tight_layout(rect=[0, 0, 1, 0.96]) 把上方 4% 留给 suptitle;整图图例用 fig.legend(handles, labels, loc="upper center", bbox_to_anchor=(0.5, 0.94), ncol=4);导出 fig.savefig("grid.png", dpi=200, bbox_inches="tight")。
  5. Seaborn 分布三件套:用一份含 1000+ 样本、5 个城市、3 个渠道的满意度数据,分别画箱线图、小提琴图、蜂群图叠加箱线图,并额外画一张「城市 × 渠道」的箱线图。要求配色用 colorblind 友好色板,图例移到图外。
    参考答案要点:sns.set_theme(style="whitegrid", palette="colorblind");sns.boxplot(data=df, x="城市", y="满意度", hue="渠道");sns.violinplot(..., inner="quart", cut=0);sns.stripplot(..., jitter=True, alpha=0.4, size=2) 后再叠加 sns.boxplot(..., showfliers=False, width=0.4);图例移出用 ax.legend(loc="upper left", bbox_to_anchor=(1.02, 1)) 或 sns.move_legend(ax, "upper left", bbox_to_anchor=(1.02, 1))。
  6. 相关系数与交叉表热力图:对含 8 个数值变量的 DataFrame 画相关系数热力图(隐藏上三角、标注数值、center=0 发散色带),并对两个类目变量画交叉表频次热力图。要求两张图并排且共用一个高度。
    参考答案要点:corr = df[num_cols].corr();mask = np.triu(np.ones_like(corr, dtype=bool));sns.heatmap(corr, mask=mask, annot=True, fmt=".2f", cmap="coolwarm", center=0, vmin=-1, vmax=1, square=True);交叉表 ct = pd.crosstab(df[a], df[b]) 再 sns.heatmap(ct, annot=True, fmt="d", cmap="YlGnBu", cbar_kws={"label":"记录数"});并排用 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5.5))。
  7. Pandas 联动与透视表出图:读入一份 CSV(字段:日期、城市、渠道、销售额、满意度),要求:① parse_dates 解析日期;② pivot_table 后画多城市折线;③ 画各城市平均销售额的横向条形图并带误差棒(yerr 用标准差);④ 画一个「渠道占比」的环形图。
    参考答案要点:df = pd.read_csv(p, parse_dates=["日期"]);p = df.pivot_table(index="日期", columns="城市", values="销售额", aggfunc="sum").sort_index(),p.plot(marker="o");误差棒 agg = df.groupby("城市")["销售额"].agg(["mean","std"]) 然后 ax.barh(agg.index, agg["mean"], xerr=agg["std"], capsize=3);环形 ax.pie(vals, labels=idx, wedgeprops=dict(width=0.4)) 加 ax.text(0,0,str(total))。
  8. Plotly 交互与 HTML 交付:把上一题的数据用 Plotly Express 画成交互散点图(颜色=渠道,大小=销售额,悬浮显示日期与满意度),设置 hovermode="x unified";再用 update_layout 加上顶部按钮,可在折线图与柱状图之间切换;最后导出 interactive.html(CDN 模式)与 2 倍分辨率的 PNG。
    参考答案要点:fig = px.scatter(df, x="日期", y="销售额", color="渠道", size="满意度", hover_data=["满意度"], hover_name="城市");fig.update_layout(hovermode="x unified", template="plotly_white");按钮切换用 fig.update_layout(updatemenus=[dict(type="buttons", buttons=[dict(label="折线", method="relayout", args=[{"type":"scatter"}]), dict(label="柱状", method="relayout", args=[{"type":"bar"}])])]),需配合 go.Figure 的 trace 遍历改 type;fig.write_html("interactive.html", include_plotlyjs="cdn", config={"displaylogo": False});fig.write_image("p.png", scale=2)(需 pip install kaleido)。
  9. Dash 仪表板:做一个带三个下拉筛选(城市、渠道、指标)的 Dash 应用,页面含 3 个 KPI 卡片(总销售额、平均满意度、记录数)、1 个趋势图、1 个分布图、1 个占比图。要求:筛选变化时所有组件联动刷新;筛选结果为空时显示「无数据」而不是报错。
    参考答案要点:@callback(Output(...), Output(...), Input("city","value"), Input("channel","value"), Input("metric","value"));回调里先 d = df.copy() 再逐条件 d = d[d[col]==val];空数据分支 if d.empty: return [html.Div("无数据")] * 4, go.Figure(), ...(返回的 Figure 数量必须与 Output 数量一致);本地 app.run(debug=True),生产 server = app.server 交给 gunicorn/waitress。
  10. 批量出图与自动化管线:写一个脚本,读取一个 CSV,对其中每个「产品类别」生成一张 PNG 趋势图保存到 output/ 目录,并在控制台打印每张图的路径与文件大小。要求:使用 Agg 后端、中文字体、每次 plt.close()、文件名不含非法字符。
    参考答案要点:脚本首行 import matplotlib; matplotlib.use("Agg");OUT = Path("output"); OUT.mkdir(exist_ok=True);循环里 fig, ax = plt.subplots(...) → ax.plot(...) → fig.tight_layout() → fig.savefig(OUT / f"{safe}_{cat}.png", dpi=150) → plt.close(fig);文件名清洗 re.sub(r'[\\/:*?"<>|]', "_", cat);打印 os.path.getsize(p)/1024。最后把整个流程写成 CLI(参考第 23 章 argparse 模式)。
  11. 综合实战:季度经营分析报告图组:给定 4 个季度的销售数据(8~10 个字段),产出一张「A3 竖版」的综合图组:顶部 1 个 KPI 行(用 fig.text 画数字),中部 2×2 四个面板(营收趋势、各城市对比柱状、渠道占比环形、利润率热力图),底部一行脚注(数据来源与口径)。要求:统一配色、统一字体、单一 legend 风格、导出 PDF 用于打印。
    参考答案要点:fig = plt.figure(figsize=(11.7, 16.5))(A3 英寸);用 fig.add_gridspec(3, 2, height_ratios=[0.25, 1, 0.06], hspace=0.35) 分配三行;KPI 用 fig.text(x, y, "1,234", ha="center", fontsize=22, fontweight="bold");每个面板 ax = fig.add_subplot(gs[1, i]);脚注 fig.text(0.5, 0.02, "数据来源:XXX,口径:不含内部交易", ha="center", fontsize=8, color="#888");plt.rcParams.update(全局样式字典) 保证一致;fig.savefig("report.pdf", bbox_inches="tight")。
Python 完全教程 · 第 21 章