Matplotlib 常用图形:柱状图、折线图与散点图
选图形之前先回答一个问题:数据里的一行,对应图上的什么。是一个点、一根柱子,还是一条线的转折点。回答清楚了,用哪个函数基本就定了;回答不清楚,画出来的图通常也不能用。
散点图:考察两个连续变量的关系
Section titled “散点图:考察两个连续变量的关系”两个连续变量之间的关系用 ax.scatter()。以 iris 的花瓣长度和花瓣宽度为例:
import matplotlib.pyplot as pltfrom sklearn.datasets import load_iris
iris = load_iris(as_frame=True)df = iris.framedf["species"] = iris.target_names[df["target"]]
fig, ax = plt.subplots(figsize=(6, 4.5))
for name, sub in df.groupby("species"): ax.scatter(sub["petal length (cm)"], sub["petal width (cm)"], s=22, alpha=0.75, label=name)
ax.set_xlabel("Petal length (cm)")ax.set_ylabel("Petal width (cm)")ax.set_title("Iris: petal length vs petal width")ax.legend(title="species", frameon=False)
fig.savefig("iris-scatter.png", dpi=300, bbox_inches="tight")plt.show()生成的是一张散点图,150 个点分三种颜色,右侧有标题为 species 的图例。setosa 聚在左下角,花瓣长度全部小于 2.5;versicolor 居中;virginica 偏右上。setosa 与另外两类之间几乎没有重叠,这是后面所有分类模型都能把它单独分出来的原因。
scatter() 的关键参数有三个:s 是点面积(单位是平方点,不是半径),c 是颜色,alpha 是透明度。透明度在多组点重叠时很关键,alpha=0.7 左右能让重叠区域显出色深差异。
点一多就会重叠。当 x 是离散变量(剂量、处理组),同一水平上的点会叠成一条竖线,分布完全看不出来。传统的做法是给 x 加一点随机抖动:
import numpy as np
rng = np.random.default_rng(2026)sub = df[df["species"] == "setosa"]["petal length (cm)"].to_numpy()
fig, ax = plt.subplots(figsize=(6, 4.5))x = np.ones(len(sub)) + rng.normal(0, 0.04, len(sub))ax.scatter(x, sub, s=22, alpha=0.6)ax.set_xlabel("setosa")ax.set_ylabel("Petal length (cm)")plt.show()生成的是单列抖动散点图,50 个点围绕 x=1 向左右散开约 ±0.08。这种做法在 Python 里要手写,Seaborn 的 stripplot() 把它变成了一行调用,见 /python/visualization/seaborn/。
抖动是随机的,每次运行位置都不同。论文里的图要可复现,先固定 np.random.default_rng(2026),或者改用箱线图。
柱状图:计数还是已汇总的值
Section titled “柱状图:计数还是已汇总的值”ax.bar() 画竖向柱状图,ax.barh() 画横向。它的接口比 ggplot2 直白:柱高就是你传进去的数值,Matplotlib 从不替你统计。
这一点和 R 有明显区别。ggplot2 的 geom_bar() 默认会数行数,Python 里没有这个自动行为,你得先自己汇总:
counts = df["species"].value_counts().sort_index()print(counts)speciessetosa 50versicolor 50virginica 50Name: count, dtype: int64fig, ax = plt.subplots(figsize=(6, 4.5))ax.bar(counts.index, counts.values, 0.55)ax.set_ylabel("count")plt.show()生成的是三根等高柱子,高度都是 50,因为 iris 三类各取 50 个样本。0.55 是柱宽,取值在 0 到 1 之间,默认 0.8。柱宽超过 0.8 柱子会挨在一起,低于 0.5 又显得稀疏。
横向柱状图在类别标签比较长时更好读,标签水平排列不用倾斜:
fig, ax = plt.subplots(figsize=(6, 4.5))ax.barh(counts.index, counts.values, 0.55)ax.set_xlabel("count")plt.show()barh() 的第一个参数是 y 位置,第二个是柱长,顺序和 bar() 相反。
柱状图只适合比较一个数值指标。用它表现两个连续变量的关系是散点图的活,画出来读不出任何信息。
分组柱状图与堆叠柱状图
Section titled “分组柱状图与堆叠柱状图”构造一个 2×3 的剂量实验数据:两种给药方式(supp:OJ、VC),三个剂量水平,每组 10 个重复,因变量是牙长。
import numpy as npimport pandas as pd
rng = np.random.default_rng(2026)base = {("OJ", 0.5): 13.2, ("OJ", 1.0): 22.7, ("OJ", 2.0): 26.1, ("VC", 0.5): 8.0, ("VC", 1.0): 16.8, ("VC", 2.0): 26.1}
tg = pd.DataFrame([ {"supp": s, "dose": d, "len": rng.normal(base[(s, d)], 3.5)} for s in ("OJ", "VC") for d in (0.5, 1.0, 2.0) for _ in range(10)])
summary = tg.groupby(["supp", "dose"])["len"].agg(mean="mean", sd="std", n="count")summary["sem"] = summary["sd"] / np.sqrt(summary["n"])print(summary.round(2)) mean sd n semsupp doseOJ 0.5 12.78 3.06 10 0.97 1.0 22.45 2.19 10 0.69 2.0 26.75 3.30 10 1.04VC 0.5 8.83 3.17 10 1.00 1.0 19.81 5.03 10 1.59 2.0 27.16 2.89 10 0.91Matplotlib 没有 position="dodge" 这样的参数,并排柱子靠手工偏移 x 坐标实现。这是 Python 画分组柱状图的标准写法:
pivot = summary["mean"].unstack("supp")err = summary["sem"].unstack("supp")
labels = [f"{d:g}" for d in pivot.index]x = np.arange(len(labels))w = 0.35
fig, ax = plt.subplots(figsize=(6, 4.5))ax.bar(x - w / 2, pivot["OJ"], w, yerr=err["OJ"], capsize=3, label="OJ")ax.bar(x + w / 2, pivot["VC"], w, yerr=err["VC"], capsize=3, label="VC")ax.set_xticks(x, labels=labels)ax.set_xlabel("dose (mg)")ax.set_ylabel("Mean tooth length")ax.legend(title="supplement", frameon=False)plt.show()生成的是分组柱状图:三个 x 位置,每组两根并排柱子,OJ 在左、VC 在右,每根柱子上带标准误误差棒。0.5 剂量下 OJ(12.78)比 VC(8.83)高 3.95;1.0 剂量下差距缩小到 2.64;2.0 剂量下两者几乎持平(26.75 与 27.16),误差棒完全重叠。
unstack() 把 MultiIndex 的 Series 转成“行是剂量、列是给药方式”的表格,省掉了手工筛选。要注意 pivot 的行索引顺序由 groupby 的排序决定,画图前打印出来确认一遍。
堆叠柱状图用 bottom 参数把后一段抬起来:
comp = pd.DataFrame({"sample": ["S1", "S2", "S3"], "soluble": [42.0, 38.5, 45.1], "insoluble": [31.0, 35.2, 28.4]})comp["total"] = comp["soluble"] + comp["insoluble"]print(comp.round(1)) sample soluble insoluble total0 S1 42.0 31.0 73.01 S2 38.5 35.2 73.72 S3 45.1 28.4 73.5xc = np.arange(len(comp))
fig, ax = plt.subplots(figsize=(6, 4.5))ax.bar(xc, comp["soluble"], 0.55, label="soluble")ax.bar(xc, comp["insoluble"], 0.55, bottom=comp["soluble"], label="insoluble")ax.set_xticks(xc, labels=comp["sample"])ax.set_ylabel("Protein (mg)")ax.legend(frameon=False)plt.show()生成的是三根堆叠柱:绿色段是 soluble,上面接着 insoluble,总高度分别约 73、74、74 毫克。
堆叠柱状图有个先天缺陷:只有最下面一段有统一的基线,上面几段的起点随下层的值浮动,跨样本比较上面那一段根本读不出来。S1 和 S3 的 insoluble 相差只有 2.6 毫克,但 S1 的总量更低,视觉上反而显得更高。真要比较构成比例,改用百分比堆叠(先做 comp["soluble"] / comp["total"]),或者干脆画成分组柱状图。
折线图:沿有序变量变化
Section titled “折线图:沿有序变量变化”折线图表达的是沿某个有序变量的变化,所以 x 必须是数值或时间,类别型的 x 用折线图没有意义。
days = np.arange(0, 8)rate = {"control": 0.05, "low": 0.28, "high": 0.52}
ln = pd.DataFrame([ {"group": g, "day": d, "value": 5.0 * np.exp(r * d) + rng.normal(0, 0.15)} for g, r in rate.items() for d in days for _ in range(6)])
ls = ln.groupby(["group", "day"])["value"].agg(mean="mean", sd="std", n="count")ls["sem"] = ls["sd"] / np.sqrt(ls["n"])print(ls.loc["high", "mean"].round(2).to_string())day0 5.051 8.492 14.203 23.804 40.125 67.256 113.207 190.48fig, ax = plt.subplots(figsize=(6, 4.5))
for g, sub in ls.groupby("group"): d = sub.index.get_level_values("day") ax.errorbar(d, sub["mean"], yerr=sub["sem"], marker="o", capsize=3, label=g)
ax.set_xlabel("Day")ax.set_ylabel("Response")ax.legend(title="group", frameon=False)plt.show()生成的是三条带误差棒的折线,每条 8 个点。control 组从 4.90 缓慢升到 7.16,几乎平躺;high 组从 5.05 涨到 190.48,呈指数上升;low 组居中。误差棒在 high 组后期明显变长,因为该组个体差异随数值放大。
errorbar() 同时画点和线,等于 plot() 加误差棒,比分成两次调用省事。它的 yerr 接受标量、数组或 (下限, 上限) 二元组,传数组时长度必须和 x 一致。
一个常见错误是分组汇总后直接 ax.plot(sub["mean"]) 而忘了传 x。不传 x 时 Matplotlib 用行号 0、1、2… 当 x 轴,图能画出来,但坐标轴刻度是行号而不是真实的天数。汇总后索引常变成 MultiIndex,取 x 要写 sub.index.get_level_values("day")。
直方图:bins 决定了你看到什么
Section titled “直方图:bins 决定了你看到什么”ax.hist() 把一个连续变量的取值范围切成若干区间,统计每个区间里的观测数。区间个数由 bins 控制,它没有默认的最优值,选不同的值会看到不同的分布形状:
print("=== petal length 在不同 bins 下的计数 ===")for b in (4, 10, 25): c, edges = np.histogram(df["petal length (cm)"], bins=b) print(f"bins={b:2d} {[int(x) for x in c]}")=== petal length 在不同 bins 下的计数 ===bins= 4 [50, 11, 61, 28]bins=10 [37, 13, 0, 3, 8, 26, 29, 18, 11, 5]bins=25 [4, 20, 24, 2, 0, 0, 0, 0, 1, 2, 2, 3, 8, 9, 12, 8, 13, 10, 4, 12, 5, 5, 2, 1, 3]bins=4 时看到的是“50、11、61、28”这样四段,中间凹下去的地方正对应 setosa 与 versicolor 之间的真实空隙。bins=10 时中间出现了一个计数为 0 的区间,空隙更清楚。bins=25 时区间多到大量为空,剩下的计数在 0 到 24 之间抖动,看起来像噪声——多出来的形状是抽样波动,不是分布特征。
np.histogram() 返回的就是 ax.hist() 内部用的计数和边界,先用它把数字打印出来再决定 bins,比反复画图试快。研究场景常用的经验值是 bins="auto"(按 Freedman-Diaconis 规则自动计算)或 bins="fd"。
fig, ax = plt.subplots(figsize=(6, 4.5))ax.hist(df["petal length (cm)"], bins=12, edgecolor="white")ax.set_xlabel("Petal length (cm)")ax.set_ylabel("count")plt.show()生成的是 12 个区间的直方图,edgecolor="white" 给柱子加了白色描边,相邻柱子之间才有分界。默认柱宽等于区间宽度,柱子之间不留空隙,这是直方图与柱状图在视觉上的主要区别。
箱线图:一张图给出五个数
Section titled “箱线图:一张图给出五个数”箱线图(box plot)画的是中位数、上下四分位数和离群点,适合并排比较多个组的分布:
q = df.groupby("species")["petal length (cm)"].quantile([0.25, 0.5, 0.75]).unstack()print(q.round(2)) 0.25 0.50 0.75speciessetosa 1.4 1.50 1.58versicolor 4.0 4.35 4.60virginica 5.1 5.55 5.88groups = [df.loc[df["species"] == s, "petal length (cm)"].to_numpy() for s in sorted(df["species"].unique())]
fig, ax = plt.subplots(figsize=(6, 4.5))ax.boxplot(groups, tick_labels=sorted(df["species"].unique()), widths=0.5)ax.set_ylabel("Petal length (cm)")plt.show()生成的是三个箱体,中位数依次抬高(1.50、4.35、5.55)。箱体的上下边是 Q1 和 Q3,中间粗线是中位数,须延伸到 1.5 倍四分位距以内最远的那个观测值。setosa 的箱体最扁(Q1 与 Q3 相差 0.18),virginica 最宽(相差 0.78)。
注意 boxplot() 接受的是一组数组的列表,不是 DataFrame 加列名。上例用列表推导按物种切分,顺序必须和 tick_labels 一一对应,写反了标签就会张冠李戴。
须之外的点画成离群点。判定规则是手算得出来的:
for s in sorted(df["species"].unique()): v = df.loc[df["species"] == s, "sepal width (cm)"] q1, q3 = np.percentile(v, [25, 75]) iqr = q3 - q1 lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr out = v[(v < lo) | (v > hi)] print(f"{s:11s} Q1={q1:.2f} Q3={q3:.2f} IQR={iqr:.2f} 须={lo:.2f}~{hi:.2f} 离群点={len(out)} {[float(x) for x in out]}")setosa Q1=3.20 Q3=3.68 IQR=0.48 须=2.49~4.39 离群点=2 [4.4, 2.3]versicolor Q1=2.52 Q3=3.00 IQR=0.48 须=1.81~3.71 离群点=0 []virginica Q1=2.80 Q3=3.18 IQR=0.38 须=2.24~3.74 离群点=3 [3.8, 2.2, 3.8]换成萼片宽度后,setosa 出现 2 个离群点,virginica 出现 3 个。离群点不等于错误数据,它只表示这个值离主体较远。鸢尾花里花瓣更宽或更窄的个体是真实的生物学变异,直接删除会引入偏差。该不该处理,取决于这个值是不是测量错误,而不是它离群。
箱线图也不显示样本量和分布形状。两个箱体长得一样,可能一边是双峰、一边是均匀分布。每组观测数少于 10 时四分位数本身就不稳定,箱体位置会随抽样大幅摆动,这时候直接画点或者用 ax.violinplot() 更诚实。
误差棒:让不确定性可见
Section titled “误差棒:让不确定性可见”研究报告里的柱状图和折线图通常要带误差棒。只画均值不画离散程度,读者无法判断组间差异是否可靠。
pivot = summary["mean"].unstack("supp")err = summary["sem"].unstack("supp")
fig, ax = plt.subplots(figsize=(6, 4.5))ax.errorbar(pivot.index, pivot["OJ"], yerr=err["OJ"], marker="o", capsize=5, label="OJ")ax.errorbar(pivot.index, pivot["VC"], yerr=err["VC"], marker="s", capsize=5, label="VC")ax.set_xlabel("dose (mg)")ax.set_ylabel("Mean tooth length")ax.legend(title="supplement", frameon=False)plt.show()生成的是带误差棒的折线图,两种标记区分给药方式。capsize 是误差棒两端横杠的长度,设为 0 时误差棒只是一条线,容易和折线本身分不清。
误差棒的长度取决于你传什么。常见三种:
| 传什么 | 含义 | 适用场景 |
|---|---|---|
sd |
标准差 | 描述个体变异范围 |
sem |
标准误(sd / √n) | 描述均值估计的精度 |
| 置信区间半宽 | 95% CI | 统计推断,与 p 值一致 |
n=10 时 sem = sd / 3.162。上例 OJ 组在 0.5 剂量下 sd 为 3.06,sem 为 0.97,误差棒只有标准差的约三分之一。用 sd 还是 sem 必须在图注里写明,二者相差几倍,读者无法从图上分辨。审稿人要求误差棒时,通常指的是 sem 或 95% CI。
几条判断依据:
| 数据形态 | 用什么 | 注意 |
|---|---|---|
| 两个连续变量 | scatter() |
重叠多时加 alpha 或用抖动 |
| 离散 x 加连续 y,要比较分布 | boxplot() |
每组少于 10 个观测就改画点 |
| 一个汇总数值(计数、均值、总和) | bar() |
先自己汇总,Matplotlib 不会数行数 |
| 多组比较同一个汇总值 | bar() 配 x 偏移 |
手工偏移比 ggplot2 的 dodge 啰嗦 |
| x 有序(时间、剂量) | plot() / errorbar() |
汇总后别忘了传真实的 x |
| 单个连续变量的分布 | hist() |
先打印计数再定 bins |
类别超过 15 个时柱状图会糊成一片,改用横向的 barh(),标签水平排列还能读。
两个反复出现的坑
Section titled “两个反复出现的坑”y 轴不从 0 开始,柱状图会夸大差异。 下面两张图用的是同一组数据:
fig, axes = plt.subplots(1, 2, figsize=(9, 4))
axes[0].bar(labels, pivot["OJ"], 0.55)axes[0].set_ylim(0, 30)axes[0].set_title("Baseline at 0")
axes[1].bar(labels, pivot["OJ"], 0.55)axes[1].set_ylim(10, 30)axes[1].set_title("Baseline at 10")plt.show()左图 y 轴从 0 开始,三根柱子的高度比约为 1 : 1.76 : 2.09;右图 y 轴从 10 开始,同样的数据看上去第一根柱子几乎为零,夸大成“0.5 剂量几乎没有效果”。柱状图用长度编码数值,截断基线等于歪曲比例,所以柱状图的 y 轴必须从 0 开始。折线图用位置编码,截断基线可以接受,但同样要在图注里说明。
set_xticklabels() 只改标签不固定刻度位置。 刻度数量和标签数量不一致时会错位。稳妥写法是一次写全:
ax.set_xticks([0, 1, 2], labels=["0.5", "1", "2"])折线图和散点图能画了,接下来是多图排版。一组图放进同一张画布、共享坐标轴、对齐尺寸,见 /python/visualization/subplots/。如果只需要快速看分布和分组差异,Seaborn 把这些图形包成了更短的接口,见 /python/visualization/seaborn/。同样的这几种图形在 R 里的写法,见 /r/visualization/geometries/。