目录13

文章Matplotlib第 32 章

图例

Matplotlib · 第 32 / 43 章

图例 legend()

legend() 不负责「画图例内容」,它只做两件事:收集画面上带 label 的 artist,然后排版成一个说明框。所以条目长什么样(颜色、线型、marker)全是从原 artist 继承的,legend 这边只能调排版和框的样子。

Python28 行
Axes.legend(
    *handles,                # 可选:显式指定进图例的 artist;不给就扫全图所有带 label 的
    *labels,                 # 可选:与 handles 一一对应的显示文字;不给就用 artist 自己的 label
    loc="best",              # ★ 位置:11 个字符串 / 0~10 整数 / (x, y) 比例坐标
    bbox_to_anchor=None,     # ★ 基准框:2 元组=一个点;4 元组=(x, y, w, h) 一块矩形
    ncols=1,                 # 列数(3.6 起正名,旧名 ncol 仍可用)
    fontsize=None,           # 条目字号;默认 None → 跟随 rcParams['legend.fontsize'](=10)
    title=None,              # 图例标题,可含 \n
    title_fontsize=None,     # 标题字号;★ 默认 None 时恒为 10,不跟着 fontsize 变
    frameon=True,            # 画不画外框
    fancybox=True,           # 圆角框(False → 方形直角)
    shadow=False,            # 外框投影
    framealpha=0.8,          # 外框透明度
    facecolor="inherit",     # 框底色;inherit = 跟随 axes.facecolor(通常白)
    edgecolor="0.8",         # 框边色
    borderpad=0.4,           # 框内边距(字号倍数)
    labelspacing=0.5,        # 条目之间的垂直间距
    handlelength=2.0,        # 图例里那段「示意线」的长度
    handleheight=0.7,        # 示意线的高度
    handletextpad=0.8,       # 示意线与文字的间距
    columnspacing=2.0,       # 多列时列与列的间距
    borderaxespad=0.5,       # 图例与 axes 边框之间的间距
    markerscale=1.0,         # 图例里 marker 的**线性**缩放
    numpoints=1,             # 折线条目上画几个 marker
    scatterpoints=1,         # 散点条目上画几个点
    handler_map=None,        # 自定义「一个 artist → 一条图例」的映射
    **kwargs                 # 其余 Text 属性(labelcolor / labelspacing 等)
)
参数 一句话 默认值的影响
loc 放哪个角 'best' 自动避让数据,落点每次重绘可能变
bbox_to_anchor 相对哪个框定位 None → 绑在 axes 边框上
ncols 排几列 1,条目多了纵向很长
fontsize / title_fontsize 条目 / 标题字号 标题恒为 10,不跟条目字号
frameon / fancybox / shadow / framealpha 外框四件套 默认有框、圆角、无阴影、0.8 透明
facecolor / edgecolor 框底色 / 框边色 'inherit' / '0.8' 灰
borderpad / labelspacing / columnspacing / borderaxespad 四组间距 全部按字号倍数算
handlelength / handleheight / handletextpad 示意线尺寸 2.0 / 0.7 / 0.8
markerscale marker 缩放 1.0;面积按平方变
handler_map 自定义条目画法 None,按 artist 类型自动选

最小示例

Python12 行
import matplotlib.pyplot as plt
import numpy as np

x = np.arange(10)
fig, ax = plt.subplots()
ax.plot(x, x, label='线性')
ax.plot(x, x ** 1.5, label='超线性')
ax.plot(x, x ** 0.5, label='次线性')
ax.scatter(x[::3], (x ** 1.5)[::3], zorder=3, label='观测')

ax.legend()          # 一行就够:位置自动避让、条目自动取 label
plt.show()

legend 的四个常用旋钮:loc / ncols / bbox_to_anchor / 间距

图里四格分别是:默认 loc='best' 自动往空处躲;ncols=2 折成两列;bbox_to_anchor 把图例挪到轴外面;最后一格把六个间距参数一起调紧。

loc:三种给法,共 12 个位置

字符串最常用,matplotlib.legend.Legend.codes 里正好 11 个字符串键对应 0~10:

字符串 整数 落在哪儿
'best' 0 自动避让数据(默认)
'upper right' 1 右上
'upper left' 2 左上
'lower left' 3 左下
'lower right' 4 右下
'right' 5 右侧居中
'center left' 6 左侧居中
'center right' 7 右侧居中
'lower center' 8 下边居中
'upper center' 9 上边居中
'center' 10 正中
Python3 行
ax.legend(loc='upper left')     # 字符串:最可读,优先用它
ax.legend(loc=2)                # 整数:等价于 'upper left'
ax.legend(loc=(0.3, 0.7))       # 元组:轴内比例坐标,给的是图例**左下角**

loc 传元组时行为完全变了——它不再是「选一个角」,而是直接把图例左下角放到 (0.3, 0.7)(相对 axes 的 0~1 比例)。源码里就是这么算的:x = bbox.x0 + bbox.width * fx。

'best' 不是「记住了某个位置」。它每次重绘都重新跑一遍避让算法,_loc 属性自始至终是 0:

Python10 行
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [1, 2, 3], label='A')
lg = ax.legend(loc='best')
print(lg._loc)
# 输出: 0

fig.canvas.draw()
print(lg._loc, tuple(round(v, 1) for v in lg.get_window_extent().bounds))
# 输出: 0 (86.9, 390.3, 59.5, 25.1)    ← 位置变了,_loc 还是 0

所以别用 _loc 去读实际位置,要读就读 get_window_extent()。数据一改、图例变宽,'best' 就会跳到另一个角——需要稳定位置时写死 'upper left' 这类。

bbox_to_anchor:把图例钉在轴内、轴外或别处

loc 只能选「axis 框的哪个角」,bbox_to_anchor 先换掉这个基准框,再让 loc 在新框上选角。这是「图例放到轴右侧外面」的唯一正规做法。

2 元组 = 只给一个点(宽高视为 0):

Python3 行
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1))    # 轴右上角的右侧
ax.legend(loc='lower left', bbox_to_anchor=(1.02, 0))    # 轴右下角的右侧
ax.legend(loc='upper center', bbox_to_anchor=(.5, -.12)) # 轴正下方

x 略大于 1 才有空隙,写 1.0 会紧贴轴框。

4 元组 = 给一块矩形 (x, y, 宽, 高),此时 loc 的选角逻辑不变,但图例的活动范围变成了这块矩形:

Python2 行
lg = ax.legend(loc='lower left', bbox_to_anchor=(0, 1.02, 1, 0.2),
               mode='expand', ncols=3)

mode='expand' 让图例横向撑满矩形宽度,配合 4 元组才能做出「横贯整幅、条目均分」的图例条。

给了 bbox_to_anchor 就把 loc 写明确,别留默认的 'best'——best 照样会跑避让算法,落点还得等重绘才知道,白折腾。

ncols:折成多列

Python2 行
ax.legend(ncols=2)      # 两列,条目先填满第一列再进第二列
ax.legend(ncols=3, loc='upper center', bbox_to_anchor=(.5, -.1))

条目多于 5~6 条时,纵向图例会高到压住数据,这时折成横向两列、或者干脆放到轴下方。ncol 是 3.6 之前的旧名,现在仍能传(3.10 实测无警告),新代码统一写 ncols。

间距六件套:borderpad / labelspacing / columnspacing / borderaxespad

四个名字极像,管的地方完全不同,单位都是字号倍数(不是像素):

参数 管哪一段 默认
borderpad 外框内侧到条目之间的留白 0.4
labelspacing 条目与条目之间的垂直间距 0.5
columnspacing 列与列之间的水平间距 2.0
borderaxespad 图例框与 axes 边框之间的距离 0.5
Python2 行
ax.legend(borderpad=.4, labelspacing=.5, columnspacing=2.0, borderaxespad=.5)  # 默认值
ax.legend(borderpad=.2, labelspacing=.25, columnspacing=.9, borderaxespad=.2)  # 全套调紧

还有三个管「示意线」本身的:

Python3 行
ax.legend(handlelength=3.0,   # 那段线的长度:2.0 → 3.0 更宽,便于看清虚线样式
          handleheight=0.9,   # 线的高度:默认 0.7,调大会让每个条目变高
          handletextpad=0.4)  # 线与文字的间距:默认 0.8,调小让文字贴近线

handlelength 容易被误当成「像素」——它乘的是字号。同样写 2.0,fontsize=8 和 fontsize=16 出来的线长差一倍。

外框:frameon / fancybox / shadow / framealpha

Python4 行
ax.legend(frameon=False)                                    # 完全不要框
ax.legend(fancybox=False)                                   # 方角框(默认是圆角)
ax.legend(shadow=True)                                      # 加投影
ax.legend(framealpha=0.5, facecolor='white', edgecolor='0.6')  # 半透明白底 + 灰边

facecolor 的默认值是字符串 'inherit',意思是「跟 ax.get_facecolor() 走」——不是白色的字面值。所以深色主题下 legend 会自动变深,除非你手动写 facecolor='white'。

图例压在密集数据上时 framealpha=0.7 左右比全透明好读得多;全透明(framealpha=0)基本等于 frameon=False。

title 与 title_fontsize:一个必须显式写的坑

Python1 行
ax.legend(title='季度数据\n(2026)', title_fontsize=11, fontsize=9)

title 支持 \n 换行,但标题字号和条目字号是两条独立的线。title_fontsize 默认是 None,此时它取的是 rcParams['font.size'](10),不跟随 fontsize:

Python6 行
sizes = {}
for fs in (6, 8, 10, 14, 20):
    lg = ax.legend(title='T', fontsize=fs)
    sizes[fs] = lg.get_title().get_fontsize()
print(sizes)
# 输出: {6: 10.0, 8: 10.0, 10: 10.0, 14: 10.0, 20: 10.0}

条目字号一路从 6 涨到 20,标题始终 10。所以只要传了 fontsize,想让标题一起变就必须再传 title_fontsize。这是「图例整体缩小后标题突然显得特别大」的直接原因。

markerscale / numpoints / scatterpoints:图例里那条线长什么样

散点用大 s 时图例里的点会大得离谱,markerscale 缩小它。注意 markerscale 缩的是线性尺寸,而 s 是面积,所以实际面积按平方缩:

Python6 行
import numpy as np
x = np.arange(10)
ax.scatter(x, x, s=200, label='观测')
lg = ax.legend(markerscale=0.4)
print(lg.legend_handles[0].get_sizes())
# 输出: [32.]     ← 200 × 0.4² = 32,不是 80

numpoints 管折线条目上画几个 marker,但**1 是个特例**:不是「画一个点」,而是「画一条三点线、只在中间那个点标 marker」(源码里 markevery=[1]):

Python10 行
import numpy as np
ax.plot(np.arange(5), np.arange(5), marker='o', label='带点')

res = {}
for n in (1, 3):
    lg = ax.legend(numpoints=n)
    line = lg.get_lines()[0]
    res[n] = [float(v) for v in line.get_xdata()], line.get_markevery()
print(res)
# 输出: {1: ([0.0, 10.0, 20.0], [1]), 3: ([3.0, 10.0, 17.0], None)}

可见 numpoints=1 的 markevery=[1],三点只标中间那个。想看「实线 + 一个点」的效果,numpoints=1 就对了。

numpoints>1 时点按 np.linspace(起点+pad, 终点-pad, numpoints) 均分,pad 是代码里为了不让 marker 被裁掉留的边距(0.375 × 字号)。所以点不会正好落在示意线的两端——numpoints=3 得到的是 3.0 / 10.0 / 17.0,不是 0 / 10 / 20。这是「图例里两端怎么都空着一截」的原因。

scatterpoints 同理,但它管的是散点条目:

Python4 行
ax.scatter(np.arange(5), np.arange(5), label='散点')
lg = ax.legend(scatterpoints=2)     # 图例里画两个点(默认 1)
print(len(lg.legend_handles[0].get_offsets()))
# 输出: 2

显式给 handles / labels:代理图例

当图例条目和画面上的 artist 不是一一对应时(画了线但想在图例里显示一个色块),就自己造句柄:

Python18 行
import matplotlib.pyplot as plt
import matplotlib.lines as mlines
import matplotlib.patches as mpatches
import numpy as np

fig, ax = plt.subplots()
x = np.arange(10)
ax.plot(x, x, color='C0')

handles = [
    mlines.Line2D([], [], color='C0', lw=2, label='实测'),
    mlines.Line2D([], [], color='C1', lw=2, ls='--', label='预测'),
    mpatches.Patch(fc='C2', alpha=.5, label='置信区间'),
    mlines.Line2D([], [], color='none', marker='o', mfc='C3',
                  markersize=8, label='观测点'),
]
ax.legend(handles=handles, loc='upper left')
plt.show()

mlines.Line2D([], [], ...) 是空数据的线,只为图例而存在,不会画到图上。这就是官方文档里说的 “proxy artist”。

只给 handles 不给 labels,标签会变成 _child0 这种自动名:

Python4 行
l1, = ax.plot([1, 2])
lg = ax.legend(handles=[l1])
print([t.get_text() for t in lg.get_texts()])
# 输出: ['_child0']

所以两个参数要成对给。反过来,legend() 也不看 label 以外的任何东西——label 是空字符串或 None 的 artist 直接跳过。

handler_map:把多条合成一条

想在图例里显示「两条线的组合」,用 HandlerTuple:

Python13 行
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from matplotlib.legend_handler import HandlerTuple

fig, ax = plt.subplots()
ax.bar([1], [3], color='#4C72B0')
ax.bar([2], [5], color='#DD8452')

p1 = mpatches.Patch(fc='#4C72B0')
p2 = mpatches.Patch(fc='#DD8452')
ax.legend([(p1, p2)], ['两段合并成一个条目'],
          handler_map={tuple: HandlerTuple(ndivide=None, pad=0.3)})
plt.show()

handler_map 的键是句柄类型,值是对应的 handler 实例。ndivide=None 表示「按元组长度均分格子」,也可以写死数字强制分成几格。同理 HandlerLine2D / HandlerPatch / HandlerBase 都能塞进来替换默认画法。

返回对象
Python14 行
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2], label='A')
lg = ax.legend()
print(type(lg).__name__)
# 输出: Legend

print([t.get_text() for t in lg.get_texts()])
# 输出: ['A']

print(len(lg.legend_handles))
# 输出: 1
print(lg.legend_handles[0] is ax.get_lines()[0])
# 输出: False

Legend 是 Artist 的子类,能挂到 figure 上、能 set_visible(False) 临时隐藏。几个常用读法:get_texts() 拿条目文字(Text 对象,可 set_text / set_color)、get_lines() / get_patches() 拿图例里复制出来的示意图形、get_frame() 拿外框 FancyBboxPatch、get_title() 拿标题 Text。

legend_handles 里装的是图例自己的副本(已经应用过 markerscale),不是原 artist——所以改这里的对象不会影响图上的线:

Python7 行
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
ax.plot([1, 2], label='A')
lg = ax.legend()
lg.get_texts()[0].set_text('改过的文字')
print(lg.get_texts()[0].get_text())
# 输出: 改过的文字

改文字只能走这条路径,legend 没有 set_labels。整块隐藏用 lg.set_visible(False),彻底移除用 lg.remove()。

另外 Legend.set_draggable(True) 能让图例在交互窗口里被鼠标拖动(savefig 时不生效)。

注意事项

  • 同一个 Axes 上连续调两次 ax.legend(),第二次直接替换第一次,不是叠加。实测 get_children() 里的 Legend 数量从 1 到 1,不会变成 2。要多个图例必须 ax.add_artist(leg1) 把前一个挂回去,再调第二次。
  • 没有 label 的 artist 不进图例,而且会抛 UserWarning: No artists with labels found to put in legend.——不是静默跳过。
  • label 首字符是下划线的 artist 会被忽略(和 Python 里「私有变量」的约定一致),想显示就换个名字。
  • title_fontsize 默认 None 时恒为 10,不跟 fontsize 走。传了 fontsize 就顺手传 title_fontsize。
  • markerscale 缩线性尺寸,面积按平方变;s=200 + markerscale=0.4 得 32。
  • numpoints=1 是特例:三点线 + 只标中间一点,不是「只画一个点」。
  • 只给 handles 不给 labels,标签会变成 _child0、_child1 这类自动名(取的是对象的自动命名,不是 label)。
  • loc 传元组时给的是图例左下角,且是相对 bbox_to_anchor 的框算的,不是相对 axes——除非 bbox_to_anchor 没设。
  • 图例放轴外(bbox_to_anchor 的 x > 1)时,savefig 必须加 bbox_inches='tight',否则图例被裁掉。
  • legend() 不改变 zorder 逻辑:它画在上面,但密集数据仍会让图例框里的线条看起来杂乱,必要时配合 framealpha。

综合示例:轴外图例 / 横贯图例条 / 合并条目

Python36 行
import matplotlib.pyplot as plt
import numpy as np
import matplotlib.patches as mpatches
from matplotlib.legend_handler import HandlerTuple

fig, axes = plt.subplots(1, 3, figsize=(13, 3.6))
x = np.arange(10)

# ① 图例放到轴右侧外面
ax = axes[0]
ax.plot(x, x, label='线性')
ax.plot(x, x ** 1.5, '--', label='超线性')
ax.plot(x, x ** 0.5, ':', label='次线性')
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), borderaxespad=0)
ax.set_title('bbox_to_anchor 放轴外')

# ② 横贯整幅底部的图例条
ax = axes[1]
for i, c in enumerate(['C0', 'C1', 'C2', 'C3']):
    ax.plot(x, x * (i + 1) / 3, color=c, label=f'组 {i + 1}')
ax.legend(loc='upper center', bbox_to_anchor=(0, -.22, 1, .15), ncols=4,
          mode='expand', frameon=False, fontsize=9)
ax.set_title('mode="expand" 横贯条')

# ③ 用代理句柄 + HandlerTuple 合并条目
ax = axes[2]
b1 = ax.bar([1, 2], [3, 4], color='#4C72B0')
b2 = ax.bar([1, 2], [2, 1], bottom=[3, 4], color='#DD8452')
p1, p2 = mpatches.Patch(fc='#4C72B0'), mpatches.Patch(fc='#DD8452')
ax.legend([(p1, p2)], ['实际 + 计划'],
          handler_map={tuple: HandlerTuple(ndivide=None, pad=0.3)},
          loc='upper left', frameon=False)
ax.set_title('HandlerTuple 合并')

plt.tight_layout()
plt.show()

三格对着三个最常卡住的需求:图例出轴、图例横排、图例合并。

第 ② 格有个必须记住的搭配:mode='expand' 只有配 4 元组 bbox_to_anchor 才真正撑满。改成 2 元组会让它去撑一个宽高为 0 的点,实测算出来的图例宽度是 -13.9 像素,四条目直接叠在一起、彼此压字:

Python2 行
lg = ax.legend(ncols=4, mode='expand', bbox_to_anchor=(.5, -.12))   # ✗ 宽 -13.9
# 四组条目挤成一团,文字互相覆盖

宽度为负说明排版盒子已经塌了,不是「效果不明显」而是「已经坏了」。记住搭配:mode='expand' → bbox_to_anchor 必须给 4 元组。