目录13
- 图例 legend()
- 最小示例
- loc:三种给法,共 12 个位置
- bbox_to_anchor:把图例钉在轴内、轴外或别处
- ncols:折成多列
- 间距六件套:borderpad / labelspacing / columnspacing / borderaxespad
- 外框:frameon / fancybox / shadow / framealpha
- title 与 title_fontsize:一个必须显式写的坑
- markerscale / numpoints / scatterpoints:图例里那条线长什么样
- 显式给 handles / labels:代理图例
- handler_map:把多条合成一条
- 注意事项
- 综合示例:轴外图例 / 横贯图例条 / 合并条目
文章/Matplotlib/第 32 章
图例
Matplotlib · 第 32 / 43 章
图例 legend()
legend() 不负责「画图例内容」,它只做两件事:收集画面上带 label 的 artist,然后排版成一个说明框。所以条目长什么样(颜色、线型、marker)全是从原 artist 继承的,legend 这边只能调排版和框的样子。
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 类型自动选 |
最小示例
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()
图里四格分别是:默认 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 | 正中 |
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:
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):
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 的选角逻辑不变,但图例的活动范围变成了这块矩形:
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:折成多列
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 |
ax.legend(borderpad=.4, labelspacing=.5, columnspacing=2.0, borderaxespad=.5) # 默认值
ax.legend(borderpad=.2, labelspacing=.25, columnspacing=.9, borderaxespad=.2) # 全套调紧还有三个管「示意线」本身的:
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
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:一个必须显式写的坑
ax.legend(title='季度数据\n(2026)', title_fontsize=11, fontsize=9)title 支持 \n 换行,但标题字号和条目字号是两条独立的线。title_fontsize 默认是 None,此时它取的是 rcParams['font.size'](10),不跟随 fontsize:
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 是面积,所以实际面积按平方缩:
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,不是 80numpoints 管折线条目上画几个 marker,但**1 是个特例**:不是「画一个点」,而是「画一条三点线、只在中间那个点标 marker」(源码里 markevery=[1]):
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 同理,但它管的是散点条目:
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 不是一一对应时(画了线但想在图例里显示一个色块),就自己造句柄:
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 这种自动名:
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:
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 都能塞进来替换默认画法。
返回对象
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])
# 输出: FalseLegend 是 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——所以改这里的对象不会影响图上的线:
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。
综合示例:轴外图例 / 横贯图例条 / 合并条目
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 像素,四条目直接叠在一起、彼此压字:
lg = ax.legend(ncols=4, mode='expand', bbox_to_anchor=(.5, -.12)) # ✗ 宽 -13.9
# 四组条目挤成一团,文字互相覆盖宽度为负说明排版盒子已经塌了,不是「效果不明显」而是「已经坏了」。记住搭配:mode='expand' → bbox_to_anchor 必须给 4 元组。