如果你的图表里有几条曲线却没有图例,读者根本分不清哪条是实际值、哪条是预测值。我在做数据复盘报告时,因为legend()函数参数没调好,不是图例飘到图外面,就是把关键数据点挡住,返工重画的情况真不少。Matplotlib的legend()函数是控制图例内容与外观的核心接口,从图例放在哪、显示什么、几列排布,到边框、圆角、字号、间距,全部由它的参数决定。
这篇文章写给正在用Python做数据分析、科研绘图、报表输出的人。刚接触图例的新手可以照着参数清单直接抄,已经用过但总在调位置的,也能从这里找到更稳的写法。我会按"设计思路—参数拆解—实操场景—问题排查"的顺序,把legend()函数常用和不常用的参数都过一遍,最后附上我踩过的坑和固定的调参套路。
1. 先想清楚:图例的本质与legend()的设计逻辑
1.1 图例到底在解决什么问题
图例在可视化里承担的任务,是把图形元素和数据含义做一一映射。人读图的时候,眼睛先扫图形,再扫图例,然后在两者之间来回对照。如果这个对照过程需要超过两秒,这张图就不算成功。所以图例不是"加一个框框"那么简单,它决定了信息传递的效率。
很多人在画图时习惯plt.legend()一把梭,但图例在复杂场景下会失控。原因在于legend()默认是从当前axes中自动收集带label的图形对象,这个机制在简单场景下很好用,但涉及多个子图、循环绘图、自定义artist时,就会有各种意外。我把这种调用方式称为"隐式模式",它解决不了三个问题:图例顺序和绘图顺序不一致、只想显示部分曲线、plot的对象不是普通Line2D。
1.2 legend()的两种调用方式与核心设计思想
第一种是隐式调用:在绘图函数里加label参数,然后直接ax.legend()。比如ax.plot(x, y, label='收益'),再调legend(),系统会把当前axes中所有带label的Line2D对象收集起来,自动生成图例条目。这种方式写起来最省事,适合快速看一眼图形效果。
第二种是显式调用:ax.legend(handles=[line1, line2], labels=['收益', '成本']),手动指定参与图例的artist对象和显示文本。显式调用看起来多写几行,但它完全掌控顺序和内容。我自己的习惯是:单图快速验证用隐式,正式出图基本都显式传参。尤其是数据列一多、循环一多的时候,自动收集的顺序真的很难控制,排查起来也麻烦。
调用legend()之后,函数会返回一个Legend对象。这个对象可以单独保存下来,之后用remove()删除,或者用set_title()修改标题。这个细节在动态更新图例时特别关键。如果你每帧都重新调legend()而不清理旧对象,图例会越叠越多,最终画布上全是重复的图例块。
注意:legend()每次调用都会替换当前axes的legend属性。如果要在一个图上放多个图例,比如双y轴图的不同数据组,必须先用变量保存第一个Legend,再通过
ax.add_artist(leg1)把它挂到图上,否则第二次调用legend()会把第一次的图例覆盖掉。
2. 核心参数逐个拆解:从位置到样式
2.1 位置类参数:loc与bbox_to_anchor的坐标逻辑
loc是图例位置控制里最基础的参数,支持字符串和整数两种写法。直观对应关系如下:
| loc字符串 | 整数代号 | 含义 |
|---|---|---|
| '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 | 正中心 |
我的建议是只用字符串,可读性好,也不会因为记混整数代号造成位置错乱。我已经见过不止一次有人把lower和upper写反,结果图例跑到数据外面去了。
loc只决定图例放在axes内部的哪个大致区域。一旦图例内容很多、或数据本身占据了角落位置,只用loc往往不够。这时候需要bbox_to_anchor做精细锚点控制。
bbox_to_anchor接收二元组(x, y)或四元组(x, y, width, height)。默认坐标系是axes的归一化坐标,原点在左下角,右上角是(1, 1)。这个坐标系跟数据坐标、像素坐标都不一样,它表示的是"相对axes宽高的比例"。理解这一点是布置图例的钥匙。
bbox_to_anchor和loc的配合规则可以这样理解:loc负责说"图例上的哪个部位是锚点",bbox_to_anchor负责说"锚点对准哪个位置"。举个例子:
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1))这句的意思是:图例的左上角对准axes右边线外2%的位置,也就是图例整体位于axes右侧外部。如果把loc换成'upper right',那就是图例的右上角去对准(1.02, 1),效果就会不同。很多人在这里犯晕,只要记住"loc决定图例哪一头去对准,bbox决定对准到哪里"就行。
2.2 内容与布局参数:labels、handles与ncol
labels和handles是一对配合使用的参数。handles接收Artist对象列表,labels接收字符串列表,两者长度必须一致,否则会报错或显示异常。数据量大的时候可以用列表推导式生成,例如:
handles = [ax.plot(x, y, color=c)[0] for c in colors] ax.legend(handles=handles, labels=names)注意ax.plot()返回的是Line2D对象的列表,所以要取[0]。如果在循环里忘记取下标的0,handles会变成列表套列表,图例也无法正常识别。
ncol控制图例的列数,默认是1列。图例项超过6个时,单列会拉得很长,占据大量绘图区域。改成ncol=2或ncol=3后横向铺开,更省空间。但列数不是越多越好,超过4列后图例本身会很宽,在窄图上可能直接覆盖数据。
与布局相关的还有几个间距参数:columnspacing控制列与列之间距离,labelspacing控制行与行之间距离,handletextpad控制图形标记和文字的距离。这些参数的单位和字体大小相关,默认值在普通场景下够用,但图例内容密集时就需要逐个微调。我的经验是:间距参数不要一次调太多,一次只改一个,前后对比效果。
2.3 外观类参数:frameon、fancybox、shadow与字号控制
frameon控制是否显示图例边框,默认True。科学论文里经常要求去掉边框,用frameon=False。但去掉之后,图例文字会直接悬在图上,要保证背景和数据不冲突。fancybox控制边框是否圆角,默认True,配合framealpha可以设置边框和背景的整体透明度。
这几个参数组合起来可以实现很常见的图例风格:
ax.legend(loc='upper right', frameon=True, fancybox=True, framealpha=0.6, edgecolor='0.8', shadow=False)framealpha设为0.6,背景半透明,图例压住数据点时还能隐约看到下面的线,视觉上不突兀。shadow控制阴影,默认False,除非你在做海报风格的图,否则不建议开,打印出来容易显得脏。
fontsize控制图例文字大小。可以直接传数字,比如fontsize=10,也可以传相对字号'xx-small'、'small'、'medium'、'large'、'xx-large'。我的建议是:所有正式导出的图统一用一个字号,比如正文10号、图例标题11号,不要一张图一个字号,整体风格会非常散。
除了fontsize,还有一个容易被忽略的markerscale。它控制图例中标记相对原图形的缩放比例。默认是1,但如果图上线条很粗、标记很大,图例里的符号可能显得过小,调成2或3可以让图例更清晰。numpoints控制折线图图例中显示的点的数量,默认来自rcParams,通常能自动生成;但在折线很密的图中,设置numpoints=1只显示一个点,图例会简洁很多。散点图则用scatterpoints控制图例中散点的个数。
2.4 文本与标题参数:title、labelcolor与alignment
图例可以带一个总标题,用title参数传入即可。title_fontsize单独控制标题字号,默认不带。标题默认居中,如果想左对齐,设置alignment='left'。这个alignment在较新版本的Matplotlib里才可用,如果运行报错,先检查一下版本。
labelcolor是一个我很常用的参数。它可以传一组颜色字符串,也可以传'on'。当设为'on'时,图例文字的每个标签会自动使用对应线条或标记的颜色,看的时候不用来回对照颜色,视觉上非常清晰。我有一次画地表覆盖分类图,使用了这个参数,审图的人一眼就分清了每一类对应的颜色。
markerfirst控制图例中标记和文字的位置关系,True表示标记在前、文字在后,一般保持默认。reverse参数可以把图例整体顺序颠倒,适合在语义上需要逆序展示的场景,但需要较新版本Matplotlib才支持,旧版本会报错。
handler_map属于高级用法。它给自定义artist注册图例处理器。比如你画了一个自定义的PathPatch,默认图例不知道该用什么符号表示它,这时需要注册一个HandlerPatch,告诉legend()如何用图标表示这个对象。这个机制扩展性很强,但日常画图用到的概率不高,遇到统一类型对象无法生成图例时再查文档即可。
3. 完整实操:从单图到多子图的四个场景
3.1 场景一:单图科学报告级图例
先看一个基础案例,把上面提到的主参数串起来:
import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 200) y1 = np.sin(x) y2 = np.cos(x) y3 = x * 0.1 fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(x, y1, '-', label='sin(x)', alpha=0.9) ax.plot(x, y2, '--', label='cos(x)', alpha=0.9) ax.plot(x, y3, ':', label='0.1*x', alpha=0.9) ax.legend(loc='upper right', frameon=True, fancybox=True, shadow=False, fontsize=10, ncol=1, handlelength=2, borderpad=0.8) plt.show()handlelength=2控制图例中线条示例的长度,数值越大示例线越长。borderpad=0.8控制图例内部边距,数值越大内部留白越多。这两个参数组合起来,能让图例块看起来不拥挤也不空洞。如果你发现图例里的线条示例短得看不清线型,优先调大handlelength而不是整体放大图例。
3.2 场景二:把图例放到画布外侧
图例放在axes内部,必然占用数据绘图区域。当数据本身挤占整个画布时,正确做法是把图例挪到axes外面。这是被问得最多的场景。
fig, ax = plt.subplots(figsize=(7, 5)) ax.plot(x, y1, label='sin(x)') ax.plot(x, y2, label='cos(x)') ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1.0), borderaxespad=0) fig.subplots_adjust(right=0.75) plt.show()这里有两个关键点。第一,bbox_to_anchor=(1.02, 1.0)把图例放在axes右边外侧,loc用'upper left',让图例左上角对准锚点。第二,也是很多人容易漏掉的:需要在figure层面给右侧留出空白,否则图例虽然画出来了,但会被figure边缘直接裁掉。
subplots_adjust(right=0.75)的意思是axes只占画布横向的前75%,后面25%留给图例。如果用的是plt.tight_layout(),它的自动调整不一定能为图例预留足够空间,还是手动留白更可控。保存图片时,也可以给savefig()加bbox_inches='tight'参数,它会自动扩展画布范围容纳所有元素,但会把图片尺寸改掉。固定尺寸输出的场景下,建议手动留白。
3.3 场景三:多子图的统一图例
多个子图时有两种策略:每个子图各自一个图例,或者整张图只放一个总图例。我的建议是优先用总图例,尤其当各子图表示同一组指标时。每个子图都放一个图例会占据大量空间,读者还得挨个子图对照,效率很低。
fig, axes = plt.subplots(1, 3, figsize=(12, 4)) labels = ['方案A', '方案B', '方案C'] colors = ['#1f77b4', '#ff7f0e', '#2ca02c'] handles = [] for ax, label, color in zip(axes, labels, colors): line, = ax.plot(x, np.sin(x + np.pi/2 * labels.index(label)), color=color, label=label) handles.append(line) fig.legend(handles=handles, labels=labels, loc='upper center', bbox_to_anchor=(0.5, 1.0), ncol=3) plt.show()注意这里用的是fig.legend()而不是ax.legend()。fig.legend()默认的坐标系统是figure的归一化坐标,所以bbox_to_anchor=(0.5, 1)表示锚点在fig中间、顶部。这个坐标系统和ax.legend()的axes坐标不同,用混了就会出现图例不知道飘到哪里的问题。
3.4 场景四:动态更新图例
做动画或交互式界面时,数据每一帧都在变化,图例内容可能跟着变。如果你在循环里直接反复调用ax.legend(),每次都会创建一个新Legend对象,旧对象虽然被替换,但图例在重新绘制时可能残留或闪烁。正确处理方式是把Legend对象保存下来,更新前先删除:
leg = None def update(): global leg if leg is not None: leg.remove() leg = ax.legend(handles=handles, labels=labels) fig.canvas.draw_idle()另一种方式是更新已有Legend的文本,而不重新创建对象。Legend有get_texts()和set_title()等方法,可以精细控制已存在的文本内容。但整体上,删除后重建更直观,也不容易出隐藏bug。
4. 常见问题排查实录:一张表加三个深坑
4.1 高频问题速查表
| 症状 | 常见原因 | 解决方案 |
|---|---|---|
| 图例完全没显示 | plot没传label,或label以下划线开头 | 检查label;改用显式handles和labels |
| 图例里出现重复项 | 循环绘图反复创建了同标签artist | 显式传参,只保留需要的handles |
| 图例被图片边缘裁掉 | 图例在axes外但画布没留白 | subplots_adjust留白,或savefig加bbox_inches='tight' |
| 图例字体大小不受控制 | rcParams配置与当前设置冲突 | 统一在legend中显式传fontsize |
| 图例文字与颜色对应不上 | 隐式模式收集顺序错乱 | 用labelcolor='on'或显式传handles |
| 动画中图例越叠越多 | 每帧创建新Legend未清理 | 保存legend对象,每帧先remove再创建 |
| 中文字体显示为方块 | 当前字体不支持中文 | 设置支持中文的字体rcParams |
4.2 深坑一:label以下划线开头会被隐藏
Matplotlib有个隐藏规则:如果label以_开头,这个artist会被自动排除在图例之外。比如ax.plot(x, y, label='_hidden')这个线条不会出现在图例里。这个规则本意是方便隐藏辅助线,但新手在不了解的情况下,很容易在批量生成label时加了前缀下划线,图例少了一项却找不到原因。
解决方法是:要么去掉下划线前缀,要么直接显式传handles和labels。显式传参时,label以下划线开头也会正常显示,不会触发隐藏规则。
4.3 深坑二:bbox_to_anchor坐标系统混用
ax.legend()和fig.legend()的bbox_to_anchor坐标基准不同。ax版本基于axes归一化坐标,fig版本基于figure归一化坐标。我见过有人把bbox_to_anchor=(0.5, 1)写在ax.legend()里,以为会把图例放到整个figure的顶部中间,结果图例出现在axes的顶部中间,位置完全不对。只要记住这一句话就行:用哪个对象调legend,就用哪个对象的坐标系统。
如果要创建一个相对axes但延伸到figure外部的图例,bbox_transform=ax.transAxes可以显式指定坐标变换。这个参数通常不写,但在复杂布局里很有用。
4.4 深坑三:自动布局与手动留白的平衡
loc='best' 在多数情况下能自动选择一个不遮挡数据的位置,但"不遮挡"只是算法层面的估算,并不是真的理解语义。数据点在局部区域越密集,自动选择可能越不可靠。我的做法是:先用best快速预览,确定图例的大致方向,再根据实际遮挡情况手动指定loc或bbox_to_anchor。最终交付的图,一定不要用best。
另外,plt.tight_layout()处理子图间距时,会自动压缩axes。如果图例放在了axes外部,tight_layout很可能把图例挤压到画布边缘甚至裁剪掉。我的经验是:有外部图例时,优先用subplots_adjust手动控制边距,而不是完全依赖tight_layout;如果依赖自动布局,保存时务必加bbox_inches='tight'。
5. 几个我沿用多年的调参习惯
最后分享几个实战中沉淀下来的土办法。第一,所有正式出图的图例,优先用显式的handles和labels,不要依赖自动收集。尤其是数据列一多、循环一多的时候,自动收集的顺序真的很难控制,出问题排查起来也费劲。第二,图例位置不要幻想loc一步到位,先放右上角看效果,遮挡了就挪到外侧。图例在外部多占一点白边没有关系,但不要在数据区里硬找缝隙。第三,把图例参数封装成自己项目的默认函数,每次画图调用同一个封装,风格统一,改参数也只需要改一处。
legend()函数的参数确实多,但核心逻辑并不复杂。把"锚点与位置"和"内容从哪里来"这两件事想明白,剩下的基本是审美问题。等你自己封装过一两次图例函数之后,再回头看那些密密麻麻的参数,会发现大部分都可以固定成默认值,真正需要频繁调的,其实就那三五个。