做到真正“一张图讲清一件事”的时候,图例的重要性会被瞬间放大。调色、改坐标轴、换字体这些功夫大家都会做,但最后往往用legend()的默认值草草收场,等图导出来才发现边框压着曲线、文字和线条挤成一团、散点图和折线图的符号比例也很别扭。这些问题既不致命,却又真实拉低观感。
这篇文章准备把legend()函数的参数从里到外过一遍:默认调用是怎么工作的、loc和bbox_to_anchor的位置逻辑怎么组合才不翻车、外观和字体参数怎么跟不同出图风格匹配,以及实际项目里最常见的几种翻车现场。内容不追求把文档抄一遍,而是按我自己的使用习惯,把真正能救命的参数单独拎出来讲透。
1. 先搞清楚legend()到底在管什么
很多人对legend()的认知停留在“加个框、列出变量名”这一层,这也能解释为什么大多数图的图例都像“贴上去的”。要理解legend(),先要理解它的数据来源,以及它返回的那个Legend对象到底是什么。
legend()本质上是在当前坐标轴上收集所有带有label属性的图形元素,把这些元素对应的“句柄”(handle,也就是线条、色块、散点集合等对象)和“标签文本”(label)配对,再按照给定的布局规则摆放到一个独立图层上。它不等于简单的文字注释,它生成的是一整套可以继续操作的图形对象。
1.1 无参调用到底收集了什么
最常见的写法是:
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(8, 5)) ax.plot([1, 2, 3, 4], label="训练集") ax.plot([1, 3, 2, 4], label="验证集") ax.legend()这里的ax.legend()没有任何参数,Matplotlib 会自动从当前Axes对象里寻找所有“带 label 的 Artist”,然后把它们生成图例。关键点在于:如果没有给曲线设置label,那legend()可能什么都收集不到,不会报错,但也只会得到一个空图例框。
另一个被经常误用的做法是给legend()直接传字符串列表:
ax.plot([1, 2, 3, 4]) ax.plot([1, 3, 2, 4]) ax.legend(["训练集", "验证集"])这种写法也能出来图例,但背后的逻辑是“按当前位置顺序把字符串依次分配给已有图形元素”。一旦你之后调整了绘图顺序,或者在中间插入了一根不需要进图例的辅助线,字符串列表和曲线的对应关系就会错位,图例上显示的名称会张冠李戴。
我个人的建议非常明确:尽量用label加无参legend()的组合,让 Matplotlib 自己收集,避免人为维护顺序。如果手动传handles和labels,也必须保证两边的顺序严格对应。
1.2legend()管的是三层内容
要记住legend()的所有参数,不需要死记,只需要理解它管三个层面。
第一层是“内容层”,解决哪些元素进图例、元素的顺序、元素的句柄样式。涉及handles、labels、numpoints、scatterpoints、markerscale等参数。第二层是“位置层”,解决图例放在坐标系内还是坐标系外、跟哪个锚点对齐、图例项如何排列。涉及loc、bbox_to_anchor、ncol、mode等参数。第三层是“外观层”,解决边框、背景、圆角、阴影、字号等视觉细节。涉及frameon、facecolor、edgecolor、fancybox、shadow、fontsize等参数。
理解了这三层,后面不管遇到什么图例需求,都能立刻定位到对应参数,不会一上来就乱调。
2. 第一梯队:位置与锚定逻辑(loc与bbox_to_anchor)
图例放在哪,永远是第一个要解决的痛点。loc负责选择“对齐基准点”,bbox_to_anchor负责把这个基准点钉到某个具体坐标上。两者必须配套理解,只调其中一个经常会得到莫名其妙的位置。
2.1loc参数:从字符串到数字编码
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 | 正中心 |
数字编码是从旧版本保留下来的,现在写字符串可读性更好,但偶尔会看到老代码用loc=1这种写法,知道含义即可。
这里有个容易被忽略的点:'best'并不是真正全局最优,它只是通过一个启发式算法去寻找“重叠面积较小”的区域。算法本身不差,但它不会考虑你的业务重点。比如你的曲线右上方是数据密集区,左下方反而是空白,算法可能因为“总重叠面积小”选到右上方,结果把关键曲线挡掉了。
所以实际项目里,不要过于依赖'best',尤其是当你的图例项很多、数据分布又不规则时,手动指定角落位置更可控。
2.2bbox_to_anchor:把图例放到坐标轴外面
bbox_to_anchor是legend()中最强大也最容易被误解的参数。它的核心作用是:把loc指定的那个图例“角”,钉到bbox_to_anchor指定的点上。
举一个高频场景:把图例放到坐标轴外部的右上方。
fig, ax = plt.subplots(figsize=(8, 5)) ax.plot([1, 2, 3, 4], label="方案A") ax.plot([1, 3, 2, 4], label="方案B") ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left", borderaxespad=0)这段代码的效果是:图例的左上角,对齐到坐标为(1.02, 1)的点上。这个坐标默认是相对于当前坐标轴比例的,1就代表坐标轴右上角,1.02表示在坐标轴右侧多出的 2% 宽度处。所以整体图例在坐标轴外侧,且靠着右上角。
如果不加loc,只写bbox_to_anchor=(1.02, 1),图例的哪个角来对齐这个点并不明确,结果容易漂移。所以loc指定“图例自己的哪个角”,bbox_to_anchor指定“角要贴到哪个位置”,两者是绑定的。
当图例在坐标轴外时,还要注意保存导出问题。直接savefig()会因为图例超出画布而被裁掉,最省事的处理方式是:
fig.savefig("result.png", bbox_inches="tight", dpi=300)或者预先给右侧留出空间:
fig.subplots_adjust(right=0.75)如果是普通展示,不导出,那用tight_layout有时候也能自动调整,但可靠程度不如subplots_adjust。
2.3 四元组加上mode="expand":制造横跨整行的图例
bbox_to_anchor除了传二元组,还可以传四元组,格式是(x, y, width, height)。这时候它描述的是一个虚拟矩形,典型场景是放在坐标轴上方当“顶部标题行”:
ax.legend( bbox_to_anchor=(0, 1.02, 1, 0.12), loc="lower left", mode="expand", ncol=3, borderaxespad=0 )这里的(0, 1.02, 1, 0.12)表示:从坐标轴最左侧开始,往上 1.02 倍高度处,横向覆盖整个宽度,高度 0.12。配合loc="lower left",表示图例底部贴近这个矩形的底边;mode="expand"则让图例在水平方向拉伸,把整块宽度占满。
这个组合常用于三个图例项横排成一行,像小标题一样挂在图上方,比默认挤在右上角清爽得多。
3. 第二梯队:外观与样式参数(边框、阴影、圆角、透明度)
位置定好了,接着就是让图例和整张图的风格统一。这里的参数数量不少,但都直白,掌握核心组合就能应付大多数场景。
3.1 边框、圆角、阴影、透明度的完整配合
默认图例带一个浅色底框,但这个框放在出版图或者比赛图上往往显得突兀。常见的微调方式:
leg = ax.legend( frameon=True, facecolor="white", edgecolor="#333333", framealpha=0.9, fancybox=False, shadow=False )frameon=False:不要框,完全融入画布。适合底色很干净的图。facecolor="none":框透明,只留边界线。framealpha=0.9:背景透明度,0 为全透明,1 为完全不透明。建议不设成 1,留一点透气感。fancybox=True:圆角边框,默认是闭合的,更适合偏活泼风格。shadow=True:给图例加投影,视觉上像浮起来,但这种效果在现代风格里很容易显得脏。
经验之谈:科研论文的图,推荐fancybox=False加细边框;做演示PPT或者业务报表,可以开圆角,但阴影最好别乱用。
3.2 字号与字体调整的两个入口
控制图例文字的常见参数是fontsize,也有prop这种更底层的写法:
ax.legend(fontsize=12) # 等价写法 ax.legend(prop={"size": 12})fontsize还可以传描述性字符串,比如"small"、"medium"、"large",分别对应一组预设字号。
但中文场景下,光调字号不够,字体族经常要一起指定。Windows 下的宋体、黑体,macOS 下的苹方,名字各不相同。如果不统一,Matplotlib 会回退到默认字体,中文全部变成方框。推荐在画图前统一配置:
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "Arial"] plt.rcParams["axes.unicode_minus"] = False第二行的axes.unicode_minus是必加项,否则坐标轴的负号会变成乱码方块。字体族在prop里也可以单独设置,适合只需要图例换字体的场景。
3.3 用Patch和Line2D自己制造图例样式
有时候数据本身并不需要画出真实图形,只是想要一个“说明色块的图例”。比如你画的是柱状图,希望图例里显示背景说明色块,而不是柱体本身。这时候可以手工制造样本:
from matplotlib.patches import Patch legend_elements = [ Patch(facecolor="#2E86AB", label="类别A"), Patch(facecolor="#A23B72", label="类别B"), ] ax.legend(handles=legend_elements)折线类型也可以用类似方式:
from matplotlib.lines import Line2D legend_elements = [ Line2D([0], [0], color="#2E86AB", lw=2, label="方案A"), Line2D([0], [0], color="#A23B72", lw=2, linestyle="--", label="方案B"), ] ax.legend(handles=legend_elements)这种手工构造的方案在论文插图里很实用,尤其是当图形本身太复杂,直接用真实句柄会连带着把无关样式也带进图例。
4. 第三梯队:图例内容控制(句柄、标签、筛选与重排)
图例内容不是“画了哪些曲线就显示哪些曲线”这么简单。实际项目中经常要手动控制哪些进图例、哪些不进,以及进图例后的排列顺序。
4.1handles和labels:手动指定时的顺序陷阱
手动传参的完整写法:
line1, = ax.plot([1, 2, 3, 4], label="训练集") line2, = ax.plot([1, 3, 2, 4], label="验证集") ax.legend(handles=[line1, line2], labels=["训练集", "验证集"])这里有个新手常踩的坑:ax.plot()返回的是一个Line2D列表,即使画一条线,返回的也是一个长度为 1 的列表。如果不加逗号解包,直接写line1 = ax.plot(...),那line1就变成列表了,传给handles时会出现奇怪的嵌套问题。正确写法就是上面那样加逗号line1, = ax.plot(...)。
当然,实际项目里往往不需要手动取变量,可以用get_legend_handles_labels()一次性取回:
handles, labels = ax.get_legend_handles_labels() ax.legend(handles, labels)这个方法返回两个列表,顺序与当前图例顺序一致,可以方便地对它们做切片、倒序、合并。
4.2 用get_legend_handles_labels()做筛选和合并
多子图场景下特别有用。假设一个画布里有四个子图,你希望把四个子图的图例统一收集到整个画布的最上方,而不是每个子图各画一个。合并写法:
all_handles = [] all_labels = [] for a in axes: handles, labels = a.get_legend_handles_labels() all_handles.extend(handles) all_labels.extend(labels) fig.legend(all_handles, all_labels, loc="upper center", ncol=4, bbox_to_anchor=(0.5, 1.02))注意,如果多个子图中的对象有重复标签,这样直接extend会造成图例里出现重复项。需要在合并前做一次去重,或者用字典以 label 为键去重。
筛选也一样。只想要前两个对象的图例,直接切片:
handles, labels = ax.get_legend_handles_labels() ax.legend(handles[:2], labels[:2])这个逻辑比在绘图时控制label更灵活,因为它不要求你提前决定哪些曲线要显示。
4.3 用_nolegend_标记隐藏曲线
如果不希望某条线出现在图例里,又不想改动整套handles筛选逻辑,最省事的办法是给这条线设置特殊标签:
ax.plot(x, noise, label="_nolegend_")只要 label 是以下划线开头的字符串,Matplotlib 就会默认忽略这条线,不把它收进图例。这是个约定俗成的机制,比“不设置label”更显式,因为你知道这条线原本有名字,只是不想展示。
另一个隐蔽技巧是给曲线手动设置set_label(""),空字符串在某些版本的 Matplotlib 中会被当作“没有标签”处理,但可靠程度不如_nolegend_,所以建议统一使用下划线开头的方式。
4.4 散点、折线混编时的符号控制
当图例里既有折线又有散点时,符号大小和数量需要单独调,否则可能出现折线图例里显示两个点密密麻麻,而散点图例则点太小看不清的情况。
numpoints:控制折线图例中显示的标记点数,默认是 2,一般设成 1 更干净。scatterpoints:控制散点图图例中点标的个数,默认也是 1,除非想表达多个点,一般 1 就够。markerscale:按比例放大或缩小图例里的标记尺寸,数值大于 1 会放大。
ax.legend(numpoints=1, scatterpoints=1, markerscale=1.2)我实际出图时,markerscale经常设成 1.2 到 1.5,因为图例里的符号总体偏小,放大会让读者看得更清楚,也不会跟正文文本抢视觉。
5. 第四梯队:空隙、列数与排版细节
当图例项很多、或者图例需要放在横向空间有限的位置时,单纯调到ncol还不够,周围的间隙参数才是决定排版质感的关键。这类参数单个看都不起眼,组合起来效果立竿见影。
5.1ncol与columnspacing
ncol控制图例列数。比如你要让四个图例项排成两排,就设ncol=2;要横排成一行,就设ncol=4:
ax.legend(loc="upper center", bbox_to_anchor=(0.5, 1.08), ncol=4, columnspacing=1.0)columnspacing是列与列之间的水平间距,单位为字体大小比例。默认值是 1,在字体较大时,两列文字之间可能贴得太紧。这种情况下可以稍微调到 1.2 或 1.5。
顺带提醒,列数不是越多越好。当ncol超过 3 或 4,每个图例项的可读宽度会变小,长变量名会被截断,所以我一般只在变量名较短时用多列布局。
5.2labelspacing、handlelength、handletextpad、borderaxespad
这四个参数控制图例内部和外部的间隙:
| 参数 | 控制对象 | 默认值 | 经验值 |
|---|---|---|---|
labelspacing | 条目之间的垂直间距 | 0.5 | 0.6 到 1.0 |
handlelength | 句柄线条的长度 | 2.0 | 1.5 到 2.5 |
handletextpad | 句柄与文字之间的水平距离 | 0.8 | 1.0 到 1.4 |
borderaxespad | 图例框与坐标轴之间的间距 | 0.5 | 图例置外时设 0 |
我的习惯是:条目比较多时,labelspacing调大一点,避免文字行距挤成一条;线条型数据比较多时,handlelength缩短,不让那条示例线拖得太长;而图例放在坐标轴外时,borderaxespad=0可以让图例严丝合缝地靠在坐标轴边缘,不留白边。
这组间距单位都是以当前字体大小为基准的“相对值”,所以字体改大之后不需要重新调,它们会自动跟随变大。
5.3title与对齐方式
图例也可以有标题,用title参数:
ax.legend(title="指标说明", title_fontsize=10, labelspacing=0.5)title_fontsize默认会继承图例字号,但单独显式设置更稳。标题出现后,图例的整体高度会增加,放在坐标轴外时要留足空间。
对齐方式参数alignment在较新版本的 Matplotlib 中可用,取值为"left"、"center"、"right"。它控制图例中文本块的对齐方式,对于多列图例尤其好用。
6. 实际项目里的高频组合与进阶玩法
这里整理几个我反复使用的场景组合,基本覆盖了大部分真实需求。
6.1 右侧外部图例与版面的配合
这是做机器学习模型对比时最常用的版式之一:图例放在坐标轴右侧,正文部分占满剩余空间。
fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(x, y1, label="模型A") ax.plot(x, y2, label="模型B") ax.plot(x, y3, label="模型C") ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left", borderaxespad=0) fig.subplots_adjust(right=0.75)调整right=0.75的目的,是让坐标轴本身在画布中只占 75% 的宽度,剩下 25% 留给右侧图例。如果不做这一步,图例虽然画出来了,但坐标轴会延伸到图例下面,两者叠在一起。
6.2 多子图的共享图例
多子图共享一个图例时,要避免使用plt.legend(),因为它只会处理“当前坐标系”。正确的思路是先用循环把所有子图的句柄收集起来,再传给fig.legend()。合并时对handles和labels做顺序保持和去重:
unique_handles = [] unique_labels = [] for a in axes: handles, labels = a.get_legend_handles_labels() for h, l in zip(handles, labels): if l not in unique_labels: unique_handles.append(h) unique_labels.append(l) fig.legend(unique_handles, unique_labels, loc="upper center", ncol=4)这样图例不会出现同一名字的重复项,顺序也跟随第一次出现的顺序。
6.3 让图例可以拖动、移除
legend()返回的Legend对象是可交互的。如果做数据探索性分析,希望手动拖动图例位置,只需:
leg = ax.legend() leg.set_draggable(True)这行代码之后,图例就可以通过鼠标直接拖到任何位置,非常适合在 Jupyter 里快速尝试不同位置。同样的对象也支持leg.remove(),在画布上彻底删除图例,而不需要重新绘图。
对事件绑定比较熟练的读者还可以监听图例点击事件,实现“点击图例项隐藏曲线”这类交互,这已经不是legend()参数本身能覆盖的,但确实建立在理解handles对象结构的基础上。
7. 常见问题速查与排错记录
最后把我在实际使用中踩过的坑和排查思路整理成一张表,算是一个速查工具。
| 现象 | 大概率原因 | 解决办法 |
|---|---|---|
调了legend()但图例空白 | 曲线没有设置label,且没有手动传handles | 给曲线加label,或手动传handles |
| 图例名称和曲线顺序对不上 | 使用字符串列表传参,顺序错位 | 改用label加无参legend() |
| 导出图片时图例被裁掉 | 图例在坐标轴外但没预留空间 | savefig(bbox_inches="tight")或subplots_adjust(right=0.75) |
| 图例文字出现方框乱码 | 中文字体未配置 | 设置rcParams["font.sans-serif"] |
| 图例里出现多余的线 | 辅助线也设置了label | 辅助线设label="_nolegend_" |
| 多个子图出现多个图例 | 在每个子图里都调了legend() | 合并handles后统一用fig.legend() |
| 图例覆盖了关键数据 | loc="best"算法选择的位置不理想 | 手动指定loc并用bbox_to_anchor微调 |
| 折线图例里点太多 | numpoints未设置 | 设numpoints=1 |
| 图例字号太小或太大 | fontsize未设置 | 显式设置fontsize,不要依赖默认 |
这些坑大多不是“不知道参数”,而是“参数组合时机不对”。排错时我通常先判断是内容层的问题、位置层的问题,还是外观层的问题,再针对性检查对应参数,效率会高很多。
最后说一个我自己的习惯:我画完图后一定会做一步“图例检查”,把图例四个角落各放一遍,用眼睛确认遮挡情况。因为很多参数在静态代码里看不出问题,只有真正渲染出来才发现borderaxespad多留了那么一点空白,或者ncol多了一列导致整体偏宽。图例这件事,调参不难,难的是愿意花时间多看几眼渲染结果。希望这篇文章能帮你把legend()从“默认能用”推进到“顺手可控”的程度。