12  Seaborn:常见统计图形

Python 常用的绘图工具有多个层次。matplotlib 是基础绘图库,能控制图形、坐标轴、标题、图例、颜色和保存格式;很多其他绘图库都建立在它之上。

Seaborn 是常用的统计绘图库。它基于 matplotlib,写法更适合 pandas 的 DataFrame,可以较方便地按变量作图、按组着色、计算分组统计量,并保留 matplotlib 的细节调整能力。

本章目标是基于数据绘制常见统计图形。我们使用同一份公司年度数据,依次绘制分布图、类别比较图、散点图、折线图、热力图、分面图和多子图组合。每类图先直接画出基本效果,再添加其他参数,最后微调绘图风格。

本章使用的数据

本章使用的数据放在工作目录下的 data 文件夹中。读取时用相对路径,例如 data/company_visualization_sample.xlsx

12.1 本章内容

  1. 直方图、密度曲线和箱形图:观察单个变量的分布,比较少数组别或多个组别。
  2. 计数图和柱状图:比较类别数量和分组均值。
  3. 散点图:观察两个数值变量的关系,处理右偏金额变量和对数变量。
  4. 折线图:观察指标随时间变化的趋势。
  5. 热力图(可选):把二维汇总表转换成颜色深浅。
  6. 分面图和多子图:把多个小图放在同一张图中比较。
  7. 图例、标题、坐标轴、配色、注释、边框和留白:把图整理成更适合展示的版本。

12.2 准备工作

这里提供一个常见绘图之前的准备工作代码,可以认为是例行公事,直接粘贴到单元格中执行即可。

  • 导入 pandasnumpymatplotlib.pyplotseaborn
  • 设置 seaborn 主题和色盲友好的 Okabe-Ito 色板。
  • 重要: 设置中文字体,避免中文标题和坐标轴标签显示异常。
  • 重要: 设置负号显示,避免负数坐标轴显示异常。
  • 设置 notebook 图形输出为 SVG 格式。

常见图形格式:

  • PNG、JPG:位图格式,图片由像素组成,适合照片、截图和网页图片;放大太多可能变模糊。
  • SVG、PDF:矢量格式,图片由线条、形状和文字描述,适合统计图、流程图和论文插图。
  • 本章使用 SVG 格式。SVG 是矢量格式,任意放大,或者导出后用于打印,一般都不会变模糊。
# 导入包并设置绘图主题、色板、字体和输出格式。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

okabe_ito = ["#0072B2", "#E69F00", "#009E73", "#D55E00", "#CC79A7", "#56B4E9", "#999999"]
sns.set_theme(
    style="ticks",  # 使用带刻度、少装饰的主题
    context="notebook",  # 使用适合 notebook 阅读的字号
)
sns.set_palette(okabe_ito)  # 使用色盲友好色板。当然也可以不设置,这样将会使用默认调色板。
plt.rcParams["font.sans-serif"] = ["Hiragino Sans GB", "Microsoft YaHei", "WenQuanYi Micro Hei"]  # 支持中文
plt.rcParams["axes.unicode_minus"] = False  # 正常显示负号
%config InlineBackend.figure_formats = ["svg"]  # 使用 SVG 格式

读取数据,并生成绘图常用变量

本章使用从 CSMAR 切出的上市公司年度面板数据。数据文件不变;为了画图更清楚,只额外生成几列展示用变量。

  1. 净利率净利润_亿元 / 营业收入_亿元
  2. 负债水平:资产负债率大于等于 0.6 记为“较高”,否则记为“正常”。
  3. 行业简称:保留原始 行业名称,但绘图时使用更短的简称,避免长标签挤占图形空间。
# 读取公司年度数据,并生成后续绘图要用的变量。
annual = pd.read_excel("data/company_visualization_sample.xlsx")
annual["统计截止日期"] = pd.to_datetime(annual["统计截止日期"])
annual["年份"] = annual["统计截止日期"].dt.year
annual["净利率"] = (annual["净利润_亿元"] / annual["营业收入_亿元"]).round(4)
annual["负债水平"] = np.where(annual["资产负债率"] >= 0.6, "较高", "正常")

industry_short = {
    "计算机、通信和其他电子设备制造业": "电子设备",
    "电气机械及器材制造业": "电气设备",
    "医药制造业": "医药",
    "软件和信息技术服务业": "软件服务",
    "汽车制造业": "汽车",
    "房地产业": "房地产",
}
annual["行业简称"] = annual["行业名称"].map(industry_short)

annual[["证券代码", "股票简称", "统计截止日期", "行业名称", "行业简称", "营业收入_亿元", "净利润_亿元", "资产负债率"]].head()
证券代码 股票简称 统计截止日期 行业名称 行业简称 营业收入_亿元 净利润_亿元 资产负债率
0 403 振兴生化 2018-12-31 医药制造业 医药 8.60 0.75 0.5298
1 403 双林生物 2019-12-31 医药制造业 医药 9.16 1.58 0.3696
2 403 派林生物 2020-12-31 医药制造业 医药 10.50 1.83 0.4149
3 403 派林生物 2021-12-31 医药制造业 医药 19.72 3.92 0.1072
4 403 派林生物 2022-12-31 医药制造业 医药 24.05 5.88 0.1405
# 统计每个行业包含的公司数量。
annual.groupby("行业简称")["证券代码"].nunique().sort_values(
    ascending=False,  # 从高到低排序
).reset_index()
行业简称 证券代码
0 电子设备 32
1 电气设备 27
2 医药 24
3 软件服务 19
4 汽车 15
5 房地产 11
# 筛选 2020 年数据,并查看主要绘图变量。
annual_2020 = annual.loc[annual["年份"] == 2020].copy()
annual_2020[["股票简称", "行业简称", "营业收入_亿元", "净利润_亿元", "资产负债率", "营业毛利率", "负债水平"]].head()
股票简称 行业简称 营业收入_亿元 净利润_亿元 资产负债率 营业毛利率 负债水平
2 派林生物 医药 10.50 1.83 0.4149 0.4882 正常
8 仁和药业 医药 41.06 6.58 0.1102 0.4018 正常
14 广济药业 医药 6.88 0.70 0.3101 0.4063 正常
20 海翔药业 医药 24.71 3.20 0.1848 0.4542 正常
26 力生制药 医药 11.41 0.09 0.1434 0.6148 正常

12.3 Seaborn 通用写法

多数 seaborn 图形函数的写法相近:

sns.xxxplot(data=数据, x=横轴变量, y=纵轴变量, hue=分组变量, ...)

常用参数:

  1. data:数据表,通常是 pandas 的 DataFrame
  2. x:横轴变量。
  3. y:纵轴变量。xy 至少指定一个;有些图只需要一个变量,例如直方图可以只写 x
  4. 横向和纵向由 x / y 决定。类别变量放在 x 轴,通常得到竖向柱;类别变量放在 y 轴,通常得到横向柱。
  5. hue:分组变量。不同组通常用不同颜色显示。
  6. order / hue_order:控制分类变量的显示顺序。
  7. color / palette:设置颜色或配色方案。
  8. ax:指定把图画到哪一个坐标轴上,常用于微调图形和组合多张图。

12.4 分布图

分布图用来观察一个数值变量集中在哪里、是否偏态、有没有明显极端值。这里以 营业毛利率 为例。直方图适合看分布形状;箱形图表达的信息接近,但更适合把多组放在一起比较。

histplot() 画直方图。

常用参数:

  1. data:数据表。
  2. x / y:要绘制分布的变量;只给 x 是一维直方图,同时给 xy 是二维直方图。
  3. hue:按某个分类变量分组着色。
  4. bins:直方图分组数量。
  5. kde:是否叠加平滑曲线。
  6. multiple:多组直方图如何摆放,如 "layer" 叠放。
  7. cbar:二维直方图是否显示颜色条。
  8. ax:把图画到指定坐标轴。
# 画 2020 年营业毛利率的基础直方图。
sns.histplot(data=annual_2020, x="营业毛利率")

添加其他参数

加入 hue 可以比较两组分布;multiple="layer" 表示叠放;bins 控制区间数量;kde=True 增加平滑曲线。为了让演示更清楚,这里比较营业毛利率差异较明显的“医药”和“汽车”两个行业。

# 筛选医药和汽车行业,准备比较营业毛利率分布。
margin_compare = annual_2020.loc[annual_2020["行业简称"].isin(["医药", "汽车"])].copy()

margin_compare[["股票简称", "行业简称", "营业毛利率"]].head()
股票简称 行业简称 营业毛利率
2 派林生物 医药 0.4882
8 仁和药业 医药 0.4018
14 广济药业 医药 0.4063
20 海翔药业 医药 0.4542
26 力生制药 医药 0.6148

先看一眼 margin_compare,确认它确实只保留了“医药”和“汽车”两个行业。下面再把这张小表交给 histplot()

# 按行业分组画营业毛利率直方图和密度曲线。
sns.histplot(
    data=margin_compare,
    x="营业毛利率",
    hue="行业简称",  # 按行业分组着色
    multiple="layer",  # 多组直方图叠放在同一坐标轴上
    bins=15,  # 把数值范围分成 15 个区间
    kde=True,  # 叠加平滑密度曲线
)

微调绘图风格

展示用图形要处理颜色、标题、坐标轴和图例。分布图重叠时,用半透明减少遮挡;组别名称较短时,图例可以留在图内或紧贴图形。

plt.subplots() 用来创建图形对象和坐标轴对象。它返回两个对象:

  1. fig:整张图,也就是 figure。保存图片、设置整张图标题、安排多个子图时,经常会用到它。
  2. ax:一张具体的坐标轴,也就是 axes。seaborn 的图通常画在 ax 上,标题、坐标轴名称、图例、网格线也常通过 ax 调整。

常用参数:

  1. figsize:图形宽高,单位是英寸。
  2. nrows / ncols:创建多子图时的行数和列数。
  3. sharex / sharey:多个子图是否共用横轴或纵轴。

单张图也可以使用 fig, ax = plt.subplots()。这样写比直接画图多一步,但后续微调更清楚。

# 微调医药和汽车行业营业毛利率分布图。
fig, ax = plt.subplots(figsize=(6, 4))  # 设置图形宽高
sns.histplot(
    data=margin_compare,
    x="营业毛利率",
    hue="行业简称",  # 按行业分组着色
    multiple="layer",  # 多组直方图叠放在同一坐标轴上
    bins=16,  # 把数值范围分成 16 个区间
    kde=True,  # 叠加平滑密度曲线
    alpha=0.45,  # 设置透明度,减少重叠遮挡
    palette={"医药": "#0072B2", "汽车": "#D55E00"},  # 指定两组颜色
    ax=ax,  # 画到前面创建的坐标轴
)
ax.set(  # 设置标题和坐标轴名称
    title="2020 年医药与汽车行业营业毛利率分布",
    xlabel="营业毛利率",
    ylabel="公司数量",
)
legend = ax.get_legend()  # 取得当前图例对象
if legend is not None:
    legend.set_title("行业")  # 修改图例标题
    legend.set_frame_on(False)  # 去掉图例边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

箱形图

boxplot() 适合快速查看中位数、四分位范围和异常值。它和直方图表达的信息接近,但更紧凑,适合一次比较多组。习惯上,分类变量放在横轴,数值变量放在纵轴。

常用参数:

  1. data:数据表。
  2. x / y:分类变量和数值变量。
  3. hue:在箱形图里再分一层组别。
  4. order:分类显示顺序。
  5. color / palette:颜色或配色方案。
  6. ax:把图画到指定坐标轴。

先把箱形图要用的数据单独取出来。我们需要看到公司名称、行业和营业毛利率,确认后面画图用的是这三列。

# 取出箱形图需要的行业和营业毛利率数据。
box_data = annual_2020[["股票简称", "行业简称", "营业毛利率"]].copy()
box_data.head()
股票简称 行业简称 营业毛利率
2 派林生物 医药 0.4882
8 仁和药业 医药 0.4018
14 广济药业 医药 0.4063
20 海翔药业 医药 0.4542
26 力生制药 医药 0.6148

箱形图会给每个行业画一个箱子。画图前先看每个行业有多少家公司,样本太少的组要谨慎解释。

# 统计每个行业用于箱形图的公司数量。
box_group_size = box_data["行业简称"].value_counts().rename_axis("行业简称").reset_index(name="公司数量")
box_group_size
行业简称 公司数量
0 电子设备 32
1 电气设备 27
2 医药 24
3 软件服务 19
4 汽车 15
5 房地产 11

箱形图本身不要求先计算中位数。这里先算各行业中位数,是为了确定横轴行业的显示顺序。

# 计算各行业营业毛利率中位数。
box_median = box_data.groupby("行业简称", as_index=False)["营业毛利率"].median()
box_median.round({"营业毛利率": 3})
行业简称 营业毛利率
0 医药 0.486
1 房地产 0.315
2 汽车 0.232
3 电子设备 0.265
4 电气设备 0.291
5 软件服务 0.235

再把中位数从高到低排序,并取出行业名称列表。这个列表会传给 order 参数,控制箱形图从左到右的顺序。

# 按中位数从高到低生成箱形图行业顺序。
box_median_sorted = box_median.sort_values("营业毛利率", ascending=False)
box_order = box_median_sorted["行业简称"].tolist()
box_median_sorted.round({"营业毛利率": 3})
行业简称 营业毛利率
0 医药 0.486
1 房地产 0.315
4 电气设备 0.291
3 电子设备 0.265
5 软件服务 0.235
2 汽车 0.232

现在已经准备好两样东西:box_data 是绘图数据,box_order 是行业顺序。下面再画箱形图。

# 用准备好的数据和顺序画营业毛利率箱形图。
fig, ax = plt.subplots(figsize=(6.2, 4.2))  # 设置图形宽高
sns.boxplot(
    data=box_data,
    x="行业简称",
    y="营业毛利率",
    order=box_order,  # 按前面算出的中位数顺序显示行业
    color="#56B4E9",  # 使用统一颜色
    ax=ax,  # 画到前面创建的坐标轴
)
ax.set(title="2020 年各行业营业毛利率分布", xlabel="行业", ylabel="营业毛利率")  # 设置标题和坐标轴名称
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

# 查看营业毛利率最高的几家公司。
annual_2020[["股票简称", "行业简称", "营业毛利率"]].sort_values(
    "营业毛利率",
    ascending=False,  # 从高到低排序
).head()
股票简称 行业简称 营业毛利率
740 万兴科技 软件服务 0.9485
44 康弘药业 医药 0.8999
116 益佰制药 医药 0.7899
32 双成药业 医药 0.7766
74 九典制药 医药 0.7485

12.5 类别比较图

类别比较图用来比较不同组的数量或均值。这里先比较行业公司数量,再比较行业平均净利率。

countplot() 统计类别变量的频数

常用参数:

  1. data:数据表。
  2. x / y:要统计的类别变量。类别变量放在 x 轴时是竖向柱,放在 y 轴时是横向柱;类别标签较长时常放在 y 轴。
  3. hue:再按另一个分类变量分组。
  4. order:类别显示顺序。
  5. color / palette:颜色或配色方案。
  6. ax:把图画到指定坐标轴。
# 画各行业公司数量的基础计数图。
sns.countplot(data=annual_2020, y="行业简称")  # 类别变量放在 y 轴,得到横向柱

添加其他参数

order 控制类别显示顺序,hue 再加入一层分类。这里按行业公司数量排序,同时用颜色区分负债水平。

# 计算各行业公司数量,并保存计数图排序。
industry_count_order = annual_2020["行业简称"].value_counts().index

annual_2020["行业简称"].value_counts().reset_index()
行业简称 count
0 电子设备 32
1 电气设备 27
2 医药 24
3 软件服务 19
4 汽车 15
5 房地产 11

上面先看各行业公司数量,并把这个顺序保存到 industry_count_order。下面按这个顺序绘图,并用 ax.bar_label() 在柱体末端添加数字。

# 给横向计数图的柱体添加公司数量标注。
fig, ax = plt.subplots(figsize=(6, 4))  # 设置图形宽高
sns.countplot(
    data=annual_2020,
    y="行业简称",
    order=industry_count_order,  # 按公司数量排序显示行业
    ax=ax,  # 画到前面创建的坐标轴 
)
ax.bar_label(ax.containers[0], padding=5, fontsize=10)  # 在柱体末端添加数字
ax.margins(x=0.12)  # 给数字留出横向空间
ax.set(xlabel="公司数量", ylabel="行业")  # 设置坐标轴名称
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

如果计数图使用了 hue 分组,图中会有多组柱体。此时需要对 ax.containers 循环,分别给每一组柱体添加数字。

# 按负债水平分组画行业计数图,并给每组柱体添加数字。
ax = sns.countplot(
    data=annual_2020,
    y="行业简称",
    hue="负债水平",  # 按负债水平分组着色
    order=industry_count_order,  # 按公司数量排序显示行业
)
for container in ax.containers:
    ax.bar_label(container, padding=5, fontsize = 10)  # 分别给每组柱体添加数字
ax.margins(x=0.12)  # 给数字留出横向空间

微调绘图风格

处理类别图时,优先使用短标签;如果必须使用长标签,通常把类别放到 y 轴。图例放到图内空白处或图外右侧,但不要让图例比图形主体还宽。

# 微调按负债水平分组的行业计数图。
fig, ax = plt.subplots(figsize=(6, 4))  # 设置图形宽高
sns.countplot(
    data=annual_2020,
    y="行业简称",
    hue="负债水平",  # 按负债水平分组着色
    order=industry_count_order,  # 按公司数量排序显示行业
    palette={"正常": "#0072B2", "较高": "#D55E00"},  # 指定两组颜色
    ax=ax,  # 画到前面创建的坐标轴
)
for container in ax.containers:
    ax.bar_label(container, padding=5, fontsize = 10)  # 分别给每组柱体添加数字
ax.margins(x=0.12)  # 给数字留出横向空间
ax.set(  # 设置标题和坐标轴名称
    title="2020 年样本公司行业分布",
    xlabel="公司数量",
    ylabel="行业",
)
ax.legend(title="负债水平", loc="lower right", frameon=False)  # 设置图例标题、位置,并去掉边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

柱状图比较均值

barplot() 画柱状图。一般要指定 xy,其中一个是分类变量,另一个是数值变量。分类变量决定分组,数值变量决定柱子的长度。比如要比较每个行业的平均净利率,可以先算出一张“行业 + 平均净利率”的表,再把这张表交给 barplot()

这种“先汇总,再画图”的做法更通用。柱子的长度可以是均值、数量、中位数、比例、增长率,或者任何已经算好的指标。

常用参数:

  1. data:数据表。
  2. x / y:分类变量和数值变量。分类变量放在 x 轴、数值变量放在 y 轴时是竖向柱;分类变量放在 y 轴、数值变量放在 x 轴时是横向柱。
  3. hue:在柱状图里再分一层组别。
  4. order:分类显示顺序。
  5. errorbar:是否显示误差线,以及误差线类型。
  6. color / palette:颜色或配色方案。
  7. ax:把图画到指定坐标轴。

下面是具体例子。先构造汇总表,再指定 xy 画图。这里把行业放在 y 轴、净利率放在 x 轴,所以画出来是横向柱。

先展示 industry_margin,确认每一行是一个行业及其平均净利率。

# 计算各行业平均净利率,作为柱状图数据。
# 这里只是简单的分组求均值再排序
industry_margin = (
    annual_2020.groupby("行业简称", as_index=False)["净利率"]  # 保留行业为普通列,便于绘图
    .mean()
    .sort_values(by="净利率", ascending=False)  # 从高到低排序
)

industry_margin
行业简称 净利率
4 电气设备 0.104530
1 房地产 0.100700
3 电子设备 0.056406
0 医药 0.040125
5 软件服务 0.023889
2 汽车 -0.062340
# 用汇总表画各行业平均净利率柱状图。
fig, ax = plt.subplots(figsize=(6, 4))  # 设置图形宽高
sns.barplot(
    data=industry_margin,
    y="行业简称",
    x="净利率",
    color="#009E73",  # 使用统一颜色
    ax=ax,  # 画到前面创建的坐标轴
)
ax.bar_label(ax.containers[0], fmt="%.3f", padding=3, fontsize=9)  # 在柱体末端添加数字
ax.margins(x=0.18)  # 给数字留出横向空间
ax.set(title="2020 年各行业平均净利率", xlabel="平均净利率", ylabel="行业")  # 设置标题和坐标轴名称
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

最后补充一种直接写法。如果不预先算好汇总表,也可以把原始明细数据交给 barplot(),同时指定分类变量和数值变量。这个时候,barplot() 会自动按分类变量分组,并默认计算数值变量的均值。

# 直接用明细数据画柱状图,barplot 默认按行业计算净利率均值。
ax = sns.barplot(
    data=annual_2020,
    y="行业简称",
    x="净利率",
    errorbar=None,  # 先不显示误差线,只看均值柱体
)
ax.bar_label(ax.containers[0], fmt="%.3f", padding=3, fontsize=9)  # 在柱体末端添加数字
ax.margins(x=0.18)  # 给数字留出横向空间

12.6 散点图和金额变量

散点图用来观察两个数值变量之间的关系。这里使用 总资产_亿元营业收入_亿元:两个变量都是正数,适合展示金额变量常见的右偏和对数处理。

第一步:先画原始散点图

scatterplot() 画散点图。

常用参数:

  1. data:数据表。
  2. x / y:横轴和纵轴变量。
  3. hue:用颜色表示分类变量。
  4. style:用点形状表示分类变量。
  5. size:用点大小表示数值变量。
  6. sizes:点大小范围。
  7. alpha:透明度。
  8. ax:把图画到指定坐标轴。
# 画总资产和营业收入的基础散点图。
sns.scatterplot(data=annual_2020, x="总资产_亿元", y="营业收入_亿元")

这张图容易看到一个问题:大量公司挤在左下角,少数规模较大的公司把坐标轴拉得很长。这个现象在金额变量中很常见。

第二步:查看一个金额变量的分布

为了说明金额变量常见的右偏问题,用 总资产_亿元 一个变量做示范即可。这里要看的是:大多数公司是否集中在较小范围,少数大公司是否把横轴拉得很长。

# 查看总资产的原始分布。
sns.histplot(
    data=annual_2020,
    x="总资产_亿元",
    bins=30,  # 把数值范围分成 30 个区间
    kde=True,  # 叠加平滑密度曲线
)

总资产_亿元 明显右偏,而且取值为正。对这类变量,取对数通常能压缩极端大值,让中小规模公司的差异更容易看见。后面的散点图需要同时比较总资产和营业收入,所以两个变量都生成对数列。

第三步:取对数后再看分布

np.log() 是自然对数,要求变量为正。取对数后先展示数据,确认新变量的含义。

# 对总资产和营业收入取对数,并查看结果。
annual_2020["log_总资产"] = np.log(annual_2020["总资产_亿元"])
annual_2020["log_营业收入"] = np.log(annual_2020["营业收入_亿元"])

annual_2020[["股票简称", "总资产_亿元", "log_总资产", "营业收入_亿元", "log_营业收入"]].head()
股票简称 总资产_亿元 log_总资产 营业收入_亿元 log_营业收入
2 派林生物 16.53 2.805177 10.50 2.351375
8 仁和药业 63.05 4.143928 41.06 3.715034
14 广济药业 19.79 2.985177 6.88 1.928619
20 海翔药业 71.79 4.273745 24.71 3.207208
26 力生制药 50.22 3.916413 11.41 2.434490
# 查看总资产取对数后的分布。
sns.histplot(
    data=annual_2020,
    x="log_总资产",
    bins=30,  # 把数值范围分成 30 个区间
    kde=True,  # 叠加平滑密度曲线
)

第四步:绘制对数版散点图

这里不是只改变坐标轴显示,而是直接使用新生成的对数变量作图。

# 画对数总资产和对数营业收入的基础散点图。
sns.scatterplot(data=annual_2020, x="log_总资产", y="log_营业收入")

第五步:添加常用参数

在对数版散点图上加入 huestylesize。现在点不再挤在左下角,多层变量也更容易阅读。

# 在散点图中加入行业、负债水平和净利率映射。
sns.scatterplot(
    data=annual_2020,
    x="log_总资产",
    y="log_营业收入",
    hue="行业简称",  # 用颜色表示行业
    style="负债水平",  # 用点形状表示负债水平
    size="净利率",  # 用点大小表示净利率
    sizes=(30, 160),  # 设置点大小范围
    alpha=0.82,  # 设置透明度
)

第六步:微调绘图风格

展示用图形要控制图例、颜色、标题、坐标轴和留白。行业名称使用简称,图例放到右侧并去掉边框。

# 微调对数资产和对数收入散点图。
fig, ax = plt.subplots(figsize=(8.2, 4.8))  # 设置图形宽高
sns.scatterplot(
    data=annual_2020,
    x="log_总资产",
    y="log_营业收入",
    hue="行业简称",  # 用颜色表示行业
    style="负债水平",  # 用点形状表示负债水平
    size="净利率",  # 用点大小表示净利率
    sizes=(35, 160),  # 设置点大小范围
    alpha=0.82,  # 设置透明度
    ax=ax,  # 画到前面创建的坐标轴
)
ax.set(  # 设置标题和坐标轴名称
    title="2020 年总资产与营业收入关系(对数变量)",
    xlabel="log(总资产,亿元)",
    ylabel="log(营业收入,亿元)",
)
ax.legend(  # 设置图例
    title="行业 / 负债 / 净利率",
    bbox_to_anchor=(1.01, 1),  # 把图例放到坐标轴右侧
    loc="upper left",  # 图例左上角对齐锚点
    frameon=False,  # 去掉图例边框
    borderaxespad=0,  # 减少图例和坐标轴之间的空白
)
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

12.7 折线图

折线图用来观察指标随时间变化的趋势。年度面板数据通常先按年份或“年份 + 组别”汇总,再绘图。

lineplot() 画折线图。

常用参数:

  1. data:数据表。
  2. x / y:横轴和纵轴变量。
  3. hue:按分类变量画多条线。
  4. marker:是否在每个点上显示标记。
  5. errorbar:是否显示误差带,以及误差带类型。
  6. linewidth:线条粗细。
  7. linestyle:线型。
  8. ax:把图画到指定坐标轴。

常见点标记 marker

  • "o":圆点。
  • "s":方块。
  • "^":上三角。
  • "D":菱形。
  • None:不显示点标记。

常见线型 linestyle

  • "-":实线。
  • "--":虚线。
  • ":":点线。
  • "-.":点划线。
# 画年度净利率变化的基础折线图。
sns.lineplot(data=annual, x="年份", y="净利率")

添加其他参数

加入 hue 可以分组画多条线;marker="o" 标出每个年份的位置。

# 按行业和年份计算平均净利率,并选出 3 个行业用于折线图。
industry_year = (
    annual.groupby(["行业简称", "年份"], as_index=False)  # 分组后保留为普通列,便于绘图
    .agg(平均净利率=("净利率", "mean"))
)
line_industries = ["医药", "电气设备", "汽车"]
industry_year_3 = industry_year.loc[industry_year["行业简称"].isin(line_industries)].copy()

industry_year_3.head()
行业简称 年份 平均净利率
0 医药 2018 -0.052887
1 医药 2019 0.092358
2 医药 2020 0.040125
3 医药 2021 0.072913
4 医药 2022 0.032800

先看 industry_year_3,确认每一行是一个“行业 + 年份”的平均净利率。折线太多会影响阅读,这里只选 3 个行业示范。

# 按 3 个行业画平均净利率折线图。
sns.lineplot(
    data=industry_year_3,
    x="年份",
    y="平均净利率",
    hue="行业简称",  # 按行业画多条线
    marker="o",  # 在线上显示点标记
)

黑白打印友好的线条

如果图形要打印成黑白,不能只依靠颜色区分线条。常见做法有两种:

  1. 同一种颜色,不同线型。
  2. 同一种颜色和线型,不同点标记。

下面先演示同色不同线型。

# 用相同颜色和不同线型画 3 个行业,便于黑白打印。
fig, ax = plt.subplots(figsize=(7.2, 4.4))  # 设置图形宽高
sns.lineplot(
    data=industry_year_3,
    x="年份",
    y="平均净利率",
    hue="行业简称",  # 按行业分组
    style="行业简称",  # 用不同线型区分行业
    dashes={"医药": "", "电气设备": (4, 2), "汽车": (1, 2)},  # 指定线型
    markers=False,  # 这里先只用线型区分
    palette={"医药": "#333333", "电气设备": "#333333", "汽车": "#333333"},  # 使用同一种深色
    linewidth=1,  # 设置线条粗细
    ax=ax,  # 画到前面创建的坐标轴
)
ax.axhline(0, color="#666666", linewidth=1, linestyle="--", alpha=0.7)  # 添加 y=0 参考线
ax.set(  # 设置标题和坐标轴名称
    title="3 个行业平均净利率变化",
    xlabel="年份",
    ylabel="平均净利率",
)
ax.grid(axis="y", linestyle="--", alpha=0.25)  # 只添加横向辅助线
ax.legend(title="行业", frameon=False, loc="best")  # 设置图例标题,并去掉边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

也可以保持同一种颜色和同一种线型,只用不同点标记区分线条。

# 用相同颜色和线型、不同点标记画 3 个行业。
fig, ax = plt.subplots(figsize=(7.2, 4.4))  # 设置图形宽高
sns.lineplot(
    data=industry_year_3,
    x="年份",
    y="平均净利率",
    hue="行业简称",  # 按行业分组
    style="行业简称",  # 用不同点标记区分行业
    markers={"医药": "o", "电气设备": "s", "汽车": "^"},  # 指定点标记
    dashes=False,  # 不使用不同线型
    palette={"医药": "#333333", "电气设备": "#333333", "汽车": "#333333"},  # 使用同一种深色
    linewidth=1,  # 设置线条粗细
    ax=ax,  # 画到前面创建的坐标轴
)
ax.axhline(0, color="#666666", linewidth=1, linestyle="--", alpha=0.7)  # 添加 y=0 参考线
ax.set(title="3 个行业平均净利率变化", xlabel="年份", ylabel="平均净利率")  # 设置标题和坐标轴名称
ax.grid(axis="y", linestyle="--", alpha=0.25)  # 只添加横向辅助线
ax.legend(title="行业", frameon=False, loc="best")  # 设置图例标题,并去掉边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

12.8 热力图(可选)

热力图适合展示二维表格中的大小关系。比如“行业 × 年份”的平均净利率,用数字表能看,热力图也能直接看出高低格局。

heatmap() 画热力图。它需要二维矩阵,所以先用 pivot_table() 准备数据。

常用参数:

  1. 第一个参数:二维矩阵或二维表。
  2. annot:是否在格子里显示数字。
  3. fmt:数字显示格式。
  4. cmap:颜色映射。
  5. linewidths:格子分隔线宽度。
  6. cbar_kws:颜色条设置。
  7. ax:把图画到指定坐标轴。
# 构造行业和年份交叉的平均净利率矩阵。
margin_matrix = annual.pivot_table(
    index="行业简称",  # 行索引
    columns="年份",  # 列索引
    values="净利率",  # 单元格数值
    aggfunc="mean",  # 同一行业年份内取均值
)

margin_matrix
年份 2018 2019 2020 2021 2022 2023
行业简称
医药 -0.052887 0.092358 0.040125 0.072913 0.032800 0.051508
房地产 0.113091 0.099636 0.100700 0.107945 -0.052564 -0.021400
汽车 0.091293 0.073660 -0.062340 0.030087 0.021173 0.018460
电子设备 -0.007700 -0.059409 0.056406 -0.024063 -0.009953 -0.051878
电气设备 0.021489 -0.009141 0.104530 0.074619 0.056078 0.070400
软件服务 -0.154474 0.023226 0.023889 -0.060674 -0.069732 -0.039153
# 画平均净利率矩阵的基础热力图。
sns.heatmap(margin_matrix)

添加其他参数

annot=True 显示数字,fmt 控制数字格式,cmap 控制配色,linewidths 控制格子分隔线。

# 在热力图中加入数值标注和配色设置。
sns.heatmap(
    margin_matrix,
    annot=True,  # 在格子里显示数字
    fmt=".2f",  # 数字保留 2 位小数
    cmap="viridis",  # 使用连续型色图
    linewidths=0.5,  # 设置格子分隔线宽度
)

微调绘图风格

连续数值适合使用感知均匀的色图,例如 viridiscividis。如果数字太密,可以去掉 annot;这里格子不多,所以保留数字。

# 微调平均净利率热力图。
fig, ax = plt.subplots(figsize=(6, 4.2))  # 设置图形宽高
sns.heatmap(
    margin_matrix,
    annot=True,  # 在格子里显示数字
    fmt=".2f",  # 数字保留 2 位小数
    cmap="viridis",  # 使用连续型色图
    linewidths=0.5,  # 设置格子分隔线宽度
    cbar_kws={"label": "平均净利率", "shrink": 0.82},  # 设置颜色条标题和长度
    ax=ax,  # 画到前面创建的坐标轴
)
ax.set(title="各行业年度平均净利率", xlabel="年份", ylabel="行业")  # 设置标题和坐标轴名称
plt.tight_layout()  # 自动调整留白,减少文字重叠

12.9 图例、分面和多图组合

前面已经能画出常见图形。实际写作业或报告时,经常还要处理图例位置、是否删除图例、分面展示和多张图组合。

图例控制

图例可以移动、改标题、去边框。如果图例和标题已经表达了同样信息,也可以删除。

ax.legend() 常用参数:

  1. title:图例标题。
  2. loc:图例位置,如 "best""upper left"
  3. bbox_to_anchor:把图例放到图外时,用它指定锚点。
  4. frameon:是否显示图例边框。

ax.get_legend().remove() 用来删除已经生成的图例。

# 画资产负债率和营业毛利率散点图。
fig, ax = plt.subplots(figsize=(6, 4.5))  # 设置图形宽高
sns.scatterplot(
    data=annual_2020,
    x="资产负债率",
    y="营业毛利率",
    hue="负债水平",  # 用颜色表示负债水平
    palette={"正常": "#0072B2", "较高": "#D55E00"},  # 指定两组颜色
    ax=ax,  # 画到前面创建的坐标轴
)
ax.set(title="资产负债率与营业毛利率", xlabel="资产负债率", ylabel="营业毛利率")  # 设置标题和坐标轴名称
ax.legend(title="负债水平", loc="best", frameon=False)  # 自动选择图例位置,并去掉边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

# 调整散点图图例位置并比较显示效果。
fig, ax = plt.subplots(figsize=(6, 4.5))  # 设置图形宽高
sns.scatterplot(
    data=annual_2020,
    x="资产负债率",
    y="营业毛利率",
    hue="负债水平",  # 用颜色表示负债水平
    palette={"正常": "#0072B2", "较高": "#D55E00"},  # 指定两组颜色
    ax=ax,  # 画到前面创建的坐标轴
)
ax.get_legend().remove()  # 删除图例
ax.set(title="资产负债率与营业毛利率(无图例版本)", xlabel="资产负债率", ylabel="营业毛利率")  # 设置标题和坐标轴名称
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

分面图

如果要看 净利率资产负债率 的关系,可以先画一张普通散点图。但如果还想按类别分开比较,例如每个行业单独画一张小图,就可以使用 relplot()

relplot() 用来画关系图,可以画散点图,也可以画折线图。它的一个常见用途是分面:用 col 指定按哪个变量拆成多个列方向的小图,用 row 指定按哪个变量拆成多个行方向的小图。下面用 col="行业简称",表示每个行业单独一张散点图,再放在同一组图中比较。

分面变量要有足够样本,否则某些小图只有一两个点,看起来像空图。

常用参数:

  1. data:数据表。
  2. x / y:每个小图里的横轴和纵轴变量。
  3. col / row:按哪些变量拆成列方向或行方向的小图。
  4. col_wrap:列方向分面太多时,每行放几个小图。
  5. col_order / row_order:分面的显示顺序。
  6. hue:用颜色区分类别。
  7. height:每个小图高度。
  8. aspect:每个小图的宽高比。
  9. palette:配色方案。
# 计算分面图中的行业显示顺序。
facet_order = annual_2020["行业简称"].value_counts().index

annual_2020["行业简称"].value_counts().reset_index()
行业简称 count
0 电子设备 32
1 电气设备 27
2 医药 24
3 软件服务 19
4 汽车 15
5 房地产 11

先看每个行业的样本数。这里按行业分面,而不是同时按“行业 × 负债水平”分面,避免某些小图只有 1 个样本。下面用颜色表示负债水平。

# 按行业分面画资产负债率和净利率散点图。
g = sns.relplot(
    data=annual_2020,
    x="资产负债率",
    y="净利率",
    col="行业简称",  # 按行业拆成多个小图
    col_wrap=3,  # 每行放 3 个小图
    col_order=facet_order,  # 按样本数顺序排列小图
    hue="负债水平",  # 用颜色表示负债水平
    height=2.6,  # 每个小图的高度
    aspect=1.2,  # 每个小图的宽高比
    palette={"正常": "#0072B2", "较高": "#D55E00"},  # 指定两组颜色
)
g.set_titles("{col_name}")  # 简化每个小图标题,避免标题重叠
g.set_axis_labels("资产负债率", "净利率")  # 设置所有小图的坐标轴名称
sns.despine()  # 去掉上方和右侧边框

多图组合

plt.subplots() 创建画布和子图网格,通常返回两个对象:

  1. fig:整张图,也就是 figure。设置整张图标题、保存整张图、调整整体布局时会用到它。
  2. axes:子图组成的网格。axes[行, 列] 指向其中一张子图。每个 seaborn 函数用 ax= 指定画到哪个位置。

常用参数:

  1. nrows / ncols:子图网格的行数和列数。
  2. figsize:整张图的宽高。
  3. sharex / sharey:多个子图是否共用横轴或纵轴。

先看一个 2 行 2 列的示意图。axes[0, 0] 是第 0 行第 0 列,也就是左上角;axes[1, 1] 是第 1 行第 1 列,也就是右下角。

显示示意图代码
# 绘制 2×2 子图网格的位置示意图。
fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(5, 3.6))

for row in range(2):
    for col in range(2):
        ax = axes[row, col]
        ax.text(
            0.5,
            0.5,
            f"axes[{row}, {col}]",
            ha="center",
            va="center",
            fontsize=14,
            weight="bold",
        )
        ax.set_xticks([])
        ax.set_yticks([])
        ax.set_facecolor("#f2f2f2")

fig.suptitle("2×2 子图网格中的 axes 位置", fontsize=13)
plt.tight_layout()

正式绘图时,每一个子图的绘制方法和前面几乎完全一样。区别在于:最后要用 ax 参数指定画到哪一个坐标轴上。例如 ax=axes[0, 0] 表示画到左上角,ax=axes[1, 1] 表示画到右下角。

# 组合多个子图,集中展示几类常见图形。
fig, axes = plt.subplots(
    nrows=2,  # 子图行数
    ncols=2,  # 子图列数
    figsize=(11.5, 7.8),  # 整张图的宽高
)

sns.histplot(
    data=annual_2020,
    x="营业毛利率",
    kde=True,  # 叠加平滑密度曲线
    color="#0072B2",  # 设置颜色
    ax=axes[0, 0],  # 画到左上角子图
)
axes[0, 0].set(title="A. 营业毛利率分布", xlabel="营业毛利率", ylabel="公司数量")  # 设置子图标题和坐标轴名称

sns.boxplot(
    data=annual_2020,
    x="行业简称",
    y="营业毛利率",
    order=box_order,  # 按前面算出的中位数顺序显示行业
    color="#56B4E9",  # 使用统一颜色
    ax=axes[0, 1],  # 画到右上角子图
)
axes[0, 1].set(title="B. 各行业毛利率", xlabel="行业", ylabel="营业毛利率")  # 设置子图标题和坐标轴名称

subplot_industry_palette = dict(zip(industry_count_order, okabe_ito[:len(industry_count_order)]))  # 为每个行业指定颜色
sns.scatterplot(
    data=annual_2020,
    x="log_总资产",
    y="log_营业收入",
    hue="行业简称",  # 只用颜色表示行业,避免子图图例过于拥挤
    palette=subplot_industry_palette,  # 使用行业颜色字典
    alpha=0.78,  # 设置透明度
    ax=axes[1, 0],  # 画到左下角子图
)
axes[1, 0].set(title="C. 资产与收入(对数)", xlabel="log(总资产)", ylabel="log(营业收入)")  # 设置子图标题和坐标轴名称
axes[1, 0].legend(title="行业", frameon=False, fontsize=8, title_fontsize=9)  # 设置图例标题,并去掉边框

sns.barplot(data=industry_margin, y="行业简称", x="净利率", color="#009E73", ax=axes[1, 1])  # color 设置颜色,ax 指定子图位置
axes[1, 1].set(title="D. 行业平均净利率", xlabel="平均净利率", ylabel="")  # 设置子图标题和坐标轴名称

for ax in axes.ravel():
    sns.despine(ax=ax)  # 对每个子图去掉上方和右侧边框

fig.suptitle("2020 年样本公司概览", fontsize=15)  # 设置整张图标题
plt.tight_layout()  # 自动调整留白,减少文字重叠

12.10 补充:一张整理后的展示图

下面保留一个和本章主线不同的数据例子。它使用 seaborn 内置的 fmri 数据,展示一张经过较多整理的单图:颜色、线型、标题、图例、网格、边框和关键点标注都集中在一张图里。

这个例子不是作业要求,也不需要逐行记忆。它的作用是给你一个参照:前面学过的很多小设置,组合起来可以得到一张更像报告或论文中的图。

# 读取 fmri 内置数据,并整理成展示用折线图。
fmri = sns.load_dataset("fmri")
fmri_plot = fmri.copy()
fmri_plot["事件类型"] = fmri_plot["event"].map({"stim": "刺激", "cue": "提示"})

fig, ax = plt.subplots(figsize=(8, 4.8))  # 设置图形宽高

sns.lineplot(
    data=fmri_plot,
    x="timepoint",
    y="signal",
    hue="事件类型",  # 用颜色区分事件类型
    style="事件类型",  # 用线型和点形状区分事件类型
    markers=True,  # 在线上显示点标记
    dashes=False,  # 不使用虚线,避免和点标记同时过于复杂
    errorbar=("ci", 95),  # 显示 95% 置信区间
    linewidth=2.2,  # 设置线条粗细
    palette={"刺激": "#0072B2", "提示": "#D55E00"},  # 指定两条线的颜色
    ax=ax,  # 画到前面创建的坐标轴
)

stim_mean = (
    fmri_plot.loc[fmri_plot["事件类型"] == "刺激"]
    .groupby("timepoint")["signal"]
    .mean()
)
peak_timepoint = stim_mean.idxmax()
peak_signal = stim_mean.max()

ax.annotate(
    f"峰值:{peak_signal:.2f}",
    xy=(peak_timepoint, peak_signal),  # 箭头指向的位置
    xytext=(30, -8),  # 文字相对箭头位置的偏移
    textcoords="offset points",  # 表示 xytext 使用偏移量
    ha="left",  # 文字水平对齐方式
    va="center",  # 文字垂直对齐方式
    arrowprops={"arrowstyle": "->", "color": "#333333", "lw": 1.2},  # 设置箭头样式
)

ax.set(  # 设置标题和坐标轴名称
    title="刺激与提示事件下的信号变化",
    xlabel="时间点",
    ylabel="信号强度",
)
ax.set_xticks(range(0, 19, 3))  # 使用整数时间点作为横轴刻度
ax.grid(axis="y", linestyle="--", alpha=0.22)  # 只添加横向辅助线
ax.legend(title="事件类型", frameon=False, loc="upper right", fontsize=10, title_fontsize=10)  # 设置图例标题、位置,并去掉边框
sns.despine()  # 去掉上方和右侧边框
plt.tight_layout()  # 自动调整留白,减少文字重叠

12.11 练习

每题都要求展示图形;如果题目要求生成中间表,也要先看一眼中间表。

12.11.1 分布图练习

练习 1.1:画出 2020 年公司 净利率 的直方图。

练习 1.2:画出 2020 年 医药汽车 两个行业 营业毛利率 的重叠直方图,并加入 kde=True

12.11.2 类别比较图练习

练习 2.1:画出 2020 年样本公司所属省份数量最多的前 10 个省份的计数图。

练习 2.2:画出 2020 年不同 行业简称 的平均 资产负债率 柱状图,行业按平均资产负债率从高到低排列。

12.11.3 散点图练习

练习 3.1:画出 2020 年 总资产_亿元营业收入_亿元 的原始散点图。

练习 3.2:生成 log_总资产log_营业收入,画出这两个对数变量的散点图。

12.11.4 折线图练习

练习 4.1:按年份计算样本公司的平均 营业毛利率,展示汇总表,并画折线图。

练习 4.2:按 负债水平 和年份计算平均 净利率,展示汇总表前几行,并画带颜色分组的折线图。

12.11.5 热力图练习

练习 5.1:制作“行业简称 × 年份”的平均 资产负债率 矩阵,并画热力图。

练习 5.2:制作“行业简称 × 年份”的平均 营业毛利率 矩阵,并画带数字标注的热力图。

12.11.6 图形整理练习

练习 6.1:整理一张 资产负债率营业毛利率 的散点图,要求包含标题、坐标轴名称和合适的图例。

练习 6.2:使用 plt.subplots() 组合两张图:左边画 营业毛利率 直方图,右边画 资产负债率 箱形图。