资讯动态

Matplotlib legend()函数全解析:从底层逻辑到工程实战技巧

发布时间:2026/10/15 1:35:05 来源:尧图企业网站定制
在Matplotlib里legend()大概是我见过的最“神奇”的函数之一——不是因为它功能多复杂而是因为大多数人用它的方式其实一直在靠默认值碰运气。日常画图时大家习惯性地在代码末尾加一句plt.legend(locbest)图例出现任务完成。但一旦遇到这些需求把图例放到子图外侧、调整条目间距、只显示部分曲线、在循环里动态更新图例、让多个子图共享一个图例你就会发现legend()的参数远不止loc一个官方文档虽然全面但读起来头大很多参数的中文资料又只贴一行参数名根本不讲背后逻辑。这篇文章就把legend()函数从头到尾拆一遍重点讲清楚每个参数是“干什么用的、什么时候用、传什么值”以及我在实际项目里踩过的坑。适合经常用Python做数据可视化的朋友无论你刚入门还是已经画了几年图应该都能从里面捡到点东西。1. 图例生成的底层逻辑为什么legend()的表现和你预期不一样1.1 图例不是“画出来的”而是“收集出来的”先纠正一个最常见的误解很多人觉得legend()是往图上“加”了一个图例框。其实更准确地说它是把你绘图时标记过的“带标签对象”收集在一起然后排版成一个图例区块。import matplotlib.pyplot as plt fig, ax plt.subplots() ax.plot([1, 2, 3], [1, 4, 9], labely x^2) ax.legend()这里发生的事是ax.plot()返回了一条Line2D对象因为传了labely x^2它就自动成为一个“候选图例条目”。然后ax.legend()内部调用get_legend_handles_labels()把这个axes里所有带label属性的artist收集起来统一塞进图例。这背后就是handles和labels两组数据在起作用。1.2 handles与labels的四种传法决定图例内容搞清楚这个机制后你就知道handles和labels是legend()的两个核心参数了。它们的传法有四种组合对应四种完全不同的行为传参方式legend()的行为适用场景什么都不传自动收集axes里所有带label的artist最常见够用只传labels自动收集handles但用你给的labels覆盖原标签想改显示名又懒得重新plot只传handles用给的handles标签自动从原artist的label读取只想显示部分曲线同时传handles和labels完全手动指定条目自定义图例、代理艺术家举个实际场景你画了五条曲线但图例只想显示其中两条或者你在某个循环里画了几十根线图例只想要一条“代表线”。这时候不要改绘图逻辑直接这样写line1, ax.plot(x, y1, label原始名字A) line2, ax.plot(x, y2, label原始名字B) line3, ax.plot(x, y3) # 不想出现在图例里 ax.legend(handles[line1, line3], labels[曲线A, 曲线C])注意第二行代码里的line1, 这个逗号很关键。因为plot()返回的是Line2D对象的列表哪怕只有一个元素也是列表加个逗号才能解包成单独的Line2D对象。后面的赋值才能被handles[line1]正常接收。这个细节在新手里经常翻车报错“handles必须是Artist的可迭代对象”其实就是传了个列表进去列表里的每个元素却还是列表。2. loc参数与坐标那点事你选的“best”可能并不是最好的2.1 最全的loc取值对照表loc可能是legend()里曝光率最高的参数但很多人也就用过best和upper right。实际上loc支持十二种取值而且每个字符串都对应一个数字代码。当你从配置文件、字典参数里传loc时经常看到一堆莫名其妙的数字就是它们。loc字符串数字代码图例位置相对子图best0自动避让数据upper right1右上角upper left2左上角lower left3左下角lower right4右下角right5右侧居中center left6左侧居中center right7右侧居中lower center8底部居中upper center9顶部居中center10正中需要注意这些字符串是可以忽略大小写和空格的官方文档里写了类似upper right写成upper-right也能识别。但在正式代码里我建议统一写法别今天用连字符明天用空格否则后期统一改样式时很痛苦。2.2 best的自动避让为什么反而不稳定locbest的底层逻辑是Matplotlib会拿图例框和当前axes里的数据点做碰撞检测动态挑一个“看起来遮住最少数据”的位置。听起来很智能但我在实际项目里吃过它的亏——当数据密集、曲线横跨多个区域时best的位置选择会变得很不稳定同一份代码在不同Matplotlib版本、不同dpi下图例落点可能都不一样。更麻烦的是如果图上有散点、折线、填充区域混合best的避让算法只统计部分artist结果经常是“避开了曲线却正好压在散点上”。如果你在做需要严格定稿的报告图、论文图我的建议是抛弃best显式指定loc哪怕是upper right都行确保同一张图在任何环境重新生成都是同一个样子。对于动态交互的调试图best倒是够用的反正随时能改。3. bbox_to_anchor锚定机制的完整解析图例定位的高级姿势3.1 锚点到底是怎么算的一次讲明白bbox_to_anchor是图例定位里最劝退的参数很多人一看见坐标就头晕。其实理解它只需要记住一句话loc决定图例自身的哪个位置作为“锚点”bbox_to_anchor决定这个锚点要贴到坐标系里的哪个坐标点。举个例子。locupper right, bbox_to_anchor(1, 1)表示图例的右上角放到axes坐标系的(1, 1)点也就是图框内部右上角效果和locupper right差不多。但如果换成locupper left, bbox_to_anchor(1, 1)意思就变成图例的左上角贴到axes右上角于是图例整个跑到了axes外面、右侧上方。# 图例挂在axes外部右侧这是最常见的用法 ax.legend(locupper left, bbox_to_anchor(1.02, 1), borderaxespad0)这里的borderaxespad0含义是“图例边框和axes边框之间的间距设为0”我不加这个参数时图例和坐标系之间总有一点点空隙看着像没对齐加完就贴得很干净。3.2 二元组与四元组的区别以及modeexpand的玩法bbox_to_anchor可以传二元组(x, y)也可以传四元组(x, y, width, height)。二元组好理解就是锚点坐标。四元组多出来的width和height说的是给图例划定一个“目标放置区域”但默认情况下图例只按自身尺寸绘制不会真的充满整个区域。真正让四元组发挥威力的是配合modeexpand参数。当modeexpand时图例会在水平方向上拉伸正好填满bbox四元组指定的区域宽度。比如你想让图例横贯整个figure顶部fig, ax plt.subplots() ax.plot([1, 2, 3], [1, 2, 3], labelline) # 在整个figure的顶部区域横向铺开 ax.legend( loclower left, bbox_to_anchor(0.0, 1.02, 1.0, 0.1), modeexpand, ncol3, borderaxespad0 )这里bbox_to_anchor(0.0, 1.02, 1.0, 0.1)的意思是以axes左下角的x0、y轴上方1.02处作为区域起点区域宽度为1.0个axes宽度高度0.1。modeexpand让图例横向扩展填满这1.0的宽度ncol3则尝试把条目排成三列。这个组合特别适合做那种“图例要横跨多个子图顶部”的图表。如果不加modeexpand图例会只占据内容需要的宽度四元组里的width就没什么明显效果。3.3 在figure层面定位图例bbox_to_anchor的坐标系默认是“当前axes坐标”也就是0到1之间的相对坐标。但如果你调用的是fig.legend()那坐标系就变成了整个figure坐标系。这是两个完全不同的东西很多人混用后图例跑到了匪夷所思的位置。当你需要在多子图场景下统一放置图例时最稳妥的办法是fig, axes plt.subplots(2, 2) handles, labels [], [] for ax in axes.flat: line, ax.plot([1, 2, 3], [1, 4, 9], label趋势线) handles.append(line) labels.append(趋势线) # 在整个画布顶部居中 fig.legend(handleshandles, labelslabels, locupper center, bbox_to_anchor(0.5, 0.98), ncol2)这里bbox_to_anchor(0.5, 0.98)的(0.5)是指整个figure宽度的50%不再是某个子图的一半。这也是为什么很多人把图例放在子图外部时会发现位置和自己想像的差一大截——因为坐标系搞错了。4. 图例外观参数从字体到边框把默认的“半成品”收拾干净4.1 字号、标题与文字颜色的全套开关默认图例长得像“半成品”黑色细字体、白底黑边、没有标题。实际做图时图例的观感直接影响整张图的完成度。字号、标题、颜色这几个参数是最值得先动刀的。ax.legend( title数据类型, fontsize12, title_fontsize14, labelcolorblack, title_color#333333 )这里fontsize控制所有条目的文字大小title_fontsize单独控制标题大小。labelcolor可以接收单个颜色也可以接收一个颜色列表去分别给每一条上色。比如ax.legend(labelcolor[tab:red, tab:blue, tab:green])在版本比较老的Matplotlib里控制字体的写法是prop{size: 12, family: serif}新版虽然保留了这个写法用于FontProperties的复杂设置但简单场景直接用fontsize、fontfamily更直观。我有一段时间一直用propdict(size12)后来才发现直接fontsize12就行少写很多括号。4.2 边框、背景、透明度与圆角的取舍图例的“框”是很多人忽略的视觉元素。默认白底黑边看多了会显得死板。frameonFalse可以把边框和背景整个关掉只留文字framealpha控制背景透明度edgecolor和facecolor分别控制边框和填充色。ax.legend(frameonTrue, framealpha0.6, facecolorwhite, edgecolor0.8)这里edgecolor0.8是0到1之间的灰度值相当于浅灰色比纯黑边框柔和很多。如果你想要圆角或阴影老版本里的fancyboxTrue、shadowTrue是可以用的但从Matplotlib 3.6开始这两个布尔参数逐渐被标记为过期官方建议直接用Patch属性来控制外观。我的建议是圆角阴影这种装饰在科技论文里能不用就不用但在运营图表、汇报PPT里framealpha加浅色facecolor就足够了没必要引入未来可能废掉的参数。4.3 间距与尺寸参数速查图例条目之间的间距以及标记和文字之间的间距在外观上决定图例是“舒朗”还是“拥挤”。这几个参数官方文档藏在深处但实际非常有用参数作用我的常用值labelspacing条目之间的垂直间距0.8handlelength图例标记线段的长度2.0handletextpad标记和文字之间的水平间距0.8borderpad图例内边距边框到内容的距离0.5columnspacing多列时列与列之间的水平间距1.0numpoints线段标记在图例中显示的点数1markerscale标记在图例中的缩放倍率1.5numpoints1的意思是一条折线在图例里只显示一个数据点而不是默认显示好几个。面对那种有大量折线标记的图numpoints1加上handlelength2.0会让图例干净利落很多。这些参数几乎都是纯“体感”调校没有绝对正确的值。我的习惯是先定字体大小再调labelspacing和borderpad最后看整体是否平衡。如果你做了很多图可以把这些固定参数封装成一个统一的图例配置避免每张图调半天风格还五花八门。5. 多子图、动态更新与自定义图例条目的实战组合5.1 多子图共享一个图例不要每张子图都重复画多子图场景里最常见的丑图四个子图每个子图都带一个自己的图例内容还一模一样图例占掉大量数据区域。正确做法是收集所有子图的handles和labels只在整个figure上生成一个图例。fig, axes plt.subplots(2, 2, figsize(10, 8)) handles_all [] labels_all [] for ax in axes.flat: l1, ax.plot([1, 2, 3], [1, 4, 9], label训练集) l2, ax.plot([1, 2, 3], [2, 3, 5], label验证集) handles_all.extend([l1, l2]) labels_all.extend([训练集, 验证集]) # 去重如果每个子图都画了同样曲线handles_all里会有四份重复 unique_handles [] unique_labels [] seen set() for h, l in zip(handles_all, labels_all): if l not in seen: unique_handles.append(h) unique_labels.append(l) seen.add(l) fig.legend( handlesunique_handles, labelsunique_labels, locupper center, bbox_to_anchor(0.5, 0.98), ncollen(unique_labels) ) # 顺手把子图自己的图例都关掉 for ax in axes.flat: ax.legend_.remove() if ax.legend_ is not None else None这里有一个隐藏问题handles_all里会积累每个子图的重复句柄同一个label对应的Line2D对象是不同的所以用label去重是个简单粗暴但有效的办法。你也可以直接只从第一个子图取handles但如果子图内容不一样那样会把别的子图的曲线漏掉。5.2 用代理艺术家proxy artist造出“没有对应图形”的图例还有一个高频需求数据图本身没有对应的图形对象但你想在图例里加一个条目解释某个颜色代表什么。特别是在机器学习里画混淆矩阵、热力图、聚类结果时图例需要表达“颜色含义”而不是曲线含义。这时候就要创建代理艺术家——用matplotlib.lines.Line2D和matplotlib.patches.Patch造一些假的图形对象专门用来给图例看。from matplotlib.lines import Line2D from matplotlib.patches import Patch legend_elements [ Line2D([0], [0], colortab:blue, lw2, label预测曲线), Line2D([0], [0], markero, colorw, markerfacecolortab:red, markersize10, label异常点), Patch(facecolortab:green, alpha0.6, label绿色区域) ] fig, ax plt.subplots() ax.scatter([1, 2, 3], [4, 5, 6], ctab:red, alpha0.6) ax.legend(handleslegend_elements, locupper right)注意第二行Line2D里colorw是因为我只需要显示标记点不需要线段markerfacecolortab:red给散点标记上色markersize10控制标记大小。Patch则是通用的矩形色块适合表示分类区域、图层含义。这个技巧学会了你的图例就不再局限于“图上实际画出来的东西”。5.3 动态更新图例的两个思路以及为什么必须remove旧图例如果是交互式可视化图例需要随着数据变化而更新。很多人直接写循环for i in range(5): ax.clear() ax.plot(x, y_i, labelf第{i}次) ax.legend() plt.pause(0.5)跑起来发现图例好像还行但一旦你在循环里同时用了fig.legend()或者反复添加图例就会看到图例一层一层叠上去越来越厚。原因在于ax.legend()每次都会创建一个新的Legend Artist对象并且覆写ax.legend_这个属性引用但旧的Legend Artist对象并没有被从画布上移除只是你不再持有它的引用而已。如果Matplotlib没有自动清理它它就会继续被绘制于是重复图例越堆越多。动态更新有两个思路思路一持有一个Legend对象直接修改它的属性。leg ax.legend(labels[初始标签]) # 更新某个条目的文字 leg.get_texts()[0].set_text(新的标签) # 更新标题 leg.set_title(新标题) plt.draw()这种做法适合图例结构不变、只是文字变了的情况。但如果你想更新的是条目数量比如从两条变三条那get_texts()[0]这种按索引访问的写法就扛不住了。思路二每次先移除旧图例再重新生成。if ax.legend_ is not None: ax.legend_.remove() ax.legend(labels[新的标签1, 新的标签2]) plt.draw()ax.legend_是Axes对象里保存当前图例的公共属性判断它是否存在存在就先remove()然后重建。我在动态更新场景里基本只用思路二因为最稳不会因为条目数量变化而出错。6. 高频排查记录重复图例、中文乱码、保存时被裁剪6.1 图例重复出现的完整排查思路某次我在一个数据报告里反复改图例改着改着发现图上叠了两个一模一样的图例。排查过程我记在这里希望能帮你省点时间。第一步确认代码里是否存在多次调用legend()且没有清理旧图例。特别是交互脚本和Jupyter Notebook里反复执行同一个单元格图例是不会自己消失的。如果在Notebook里每次运行ax.legend()都会在上一张图上叠加一个新图例。这种情况下先看ax.legend_再决定是remove()还是直接重建。第二步检查同一数据是否被添加了两次label。比如你既有ax.plot(..., label曲线A)又在后面手动ax.legend(labels[曲线A])当传了labels但没传handles时图例会自动收集当前axes里的handles又把labels作为新名字贴上去两条路径叠在一起就会多出重复条目。解决办法是二选一要么plot里不设label全靠legend(labels...)要么plot里设好label末尾直接ax.legend()什么都别传。6.2 中文标签变成方块的原因和根治方案中文在Matplotlib里显示成方块是每个中文开发者都躲不过的坑。原因很简单Matplotlib默认字体里不包含中文字形。根治方案是设置全局字体import matplotlib.pyplot as plt plt.rcParams[font.sans-serif] [Microsoft YaHei, SimHei] plt.rcParams[axes.unicode_minus] False第二行的axes.unicode_minus False不是中文字体问题而是解决坐标轴负号显示成方块的问题。因为默认字体里同样可能没有Unicode的负号字形所以这两个通常一起设置。如果你所在的系统连微软雅黑、黑体都没有也可以通过font_manager手动注册一个已有的中文字体文件from matplotlib import font_manager font_path /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc font_manager.fontManager.addfont(font_path) plt.rcParams[font.family] font_manager.FontProperties(fnamefont_path).get_name()这里plot之后图例中的中文就不会再变方块了。很多教程只贴第一段第二段注册字体的方式在Docker容器、Linux服务器上尤其有效建议收藏。6.3 图例被保存图片裁掉的常见案例把图例放在axes外部时plt.show()能看到但plt.savefig()保存出来的图片常常把图例右半边直接切断。这是因为savefig默认只保存画布范围而图例的锚点在axes外部超出了画布边界。解决方法是保存时加plt.savefig(output.png, dpi150, bbox_inchestight)bbox_inchestight会让Matplotlib自动计算所有artist的实际位置把画布边界扩展到包含图例。这个方法对图例在画布里被压扁、被裁剪的问题都有效。不过我提醒一句用了bbox_inchestight之后最终输出图片的尺寸会和你预设的figsize不同因为边框被自动扩展了。需要严格统一图片尺寸时应该用pad_inches再微调或者干脆把图例放到figure坐标系的固定位置而不是用bbox_to_anchor(1.02, 1)这种“越界”方案。图例这个东西看参数列表会觉得琐碎但把底层逻辑捋一遍之后你会发现它不过就是“收集哪些对象、放在哪里、长什么样”三件事。你需要的那些参数其实都在官方文档里躺着只是没人把它们串起来讲。我现在的习惯是先想清楚图例的内容来源再定位置再调样式最后统一检查有没有踩中前面那几个高频坑。按这个顺序来绝大多数图例问题都能一次解决。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑