用Python做科学计算和数据可视化,绕不开Matplotlib;而Matplotlib里打交道最多的图形对象,就是绘图线。折线图、趋势曲线、频谱包络、模型误差曲线、训练损失曲线——这些看似不同的图,落到代码上都是在画一条线。很多同学一开始都是plt.plot(x, y)一把梭,画完才发现线型不协调、坐标轴不对、中文乱码、图例挤成一团。这篇我把Matplotlib绘图线从头到尾捋一遍:线型样式怎么选、坐标轴(尤其是对数坐标轴)怎么控制、多个子图怎么编排、中文显示和全局配置怎么一劳永逸,最后再给一套和numpy、pandas、scikit-learn联动时的完整示例。适合刚摸到Matplotlib门的新手,也适合画过不少图但总在细节上卡壳的同学。
1. 绘图线的基础认知与设计思路
1.1 为什么说线条是可视化的“第一公民”
数据可视化本质上是在做一件事:把数据里的规律转化成眼睛能快速识别的视觉信号。点能表达“某个时刻有多高”,但只有线能表达“从高到低是怎么变化的”。日活曲线、股价走势、模型loss下降、传感器读数波动,一旦被连成线,趋势、周期、异常点、收敛速度都变得一目了然,这就是线条在数据图里的地位。
画线这件事,很多人以为只要把数据点连起来就行,但实际项目里“能不能看懂”很大程度取决于线条的视觉设计。两条不同含义的线都是黑色实线,读者会盯着图发愣;线宽太细,投到屏幕上根本看不清;对数坐标下该用直线趋势表达,结果用线性轴画出一团压缩在左下角的曲线。说白了,绘图线不只是一个“画线函数”,它是一套视觉语法的载体。理解这一点,后面所有参数的学习都是在往这套语法里补词汇。
1.2 两种API:plt.plot() 与 ax.plot()
Matplotlib画线有两条路。第一种是Pyplot状态机风格,直接调用plt.plot(x, y),简单直观:
import matplotlib.pyplot as plt import numpy as np x = np.linspace(0, 10, 100) y = np.sin(x) plt.plot(x, y) plt.xlabel('x') plt.ylabel('y') plt.title('sin(x)') plt.show()第二种是面向对象风格,先创建Figure和Axes,再调用ax.plot(x, y):
fig, ax = plt.subplots(figsize=(8, 4)) ax.plot(x, y) ax.set_xlabel('x') ax.set_ylabel('y') ax.set_title('sin(x)') fig.tight_layout() plt.show()两种写法在简单场景下结果一样,但实际操作里我强烈建议养成用Axes的习惯。原因是Pyplot风格每次调用plt.xlabel()时,Matplotlib都要通过plt.gca()去猜“当前正在操作哪个坐标系”。画单张图的时候无所谓,一旦代码变长、出现多个子图、双Y轴、多次循环画线,这个“隐形状态”就成了最大的坑——你明明想改第二个子图的标题,结果改到了最近一次激活的图上。
用ax.xxx()则所有操作都显式绑定到某个Axes上,谁是谁、改哪里,一眼就清楚。尤其配合subplots()创建子图后,axes[i, j]直接定位到具体位置,代码可维护性完全不是一个量级。
| 对比项 | plt.plot() 状态机 | ax.plot() 面向对象 |
|---|---|---|
| 上手难度 | 低,适合交互式探索 | 略高,但结构清晰 |
| 多子图时 | 容易搞混当前轴 | 每个Axes独立显式控制 |
| 代码复用 | 较差 | 适合封装成函数 |
| 实际项目 | 很少用 | 推荐主力使用 |
1.3 画线前,把数据整理成Matplotlib喜欢的样子
不管用哪种API,plot()接收的数据本质上都是三类:numpy数组、Python列表、pandas的Series或DataFrame列。画线时x和y必须长度一致,这是最基础也是新手最容易踩的坑——DataFrame里有几行NaN,或者groupby之后索引对不齐,画出来就是一条断掉的线或者直接报错。
import pandas as pd # 从DataFrame取两列直接画线 df = pd.DataFrame({ 'time': pd.date_range('2024-01-01', periods=100, freq='D'), 'value': np.random.randn(100).cumsum() }) fig, ax = plt.subplots() ax.plot(df['time'], df['value']) plt.show()pandas的日期列可以直接作为x轴,但有个常见的细节:如果日期列是字符串类型,Matplotlib会把它当成一堆离散类别,横轴刻度会非常拥挤。建议先pd.to_datetime()转成datetime类型再画。数值列如果有NaN,线会在NaN处断开,有时候这恰恰是好事——能直观看到数据缺口在哪;有时候不是,需要先fillna()或用插值补上。这是画线前必须想清楚的一步,数据的预处理质量直接决定线的可信度。
2. 核心细节:线条的样式体系
2.1 线型与颜色:先定“性格”,再调“皮肤”
Matplotlib线型就那么几种,但选错很容易让图变得很“廉价”。最常用的有四种:实线'-'、虚线'--'、点划线'-.'、点线':'。一张图里如果有多条含义并列的线,我的习惯是先区分颜色,再在关键的一条(比如基线、参考线)上用虚线或点划线做二次区分,而不是每条线都用不同线型,那样视觉上会很花。
颜色的写法有四种:单字符颜色'r'、十六进制'#1f77b4'、RGB元组(0.2, 0.4, 0.6)、颜色名称'orange'。实际项目里,我几乎不用单字符颜色,因为可选范围太小、颜色太“生”。更推荐十六进制,既能精确控制色值,也和设计稿、品牌色的风格容易对齐。
fig, ax = plt.subplots(figsize=(9, 4)) x = np.linspace(0, 2 * np.pi, 200) ax.plot(x, np.sin(x), linestyle='-', color='#1f77b4', label='实线') ax.plot(x, np.sin(x + 1), linestyle='--', color='#ff7f0e', label='虚线') ax.plot(x, np.sin(x + 2), linestyle='-.', color='#2ca02c', label='点划线') ax.plot(x, np.sin(x + 3), linestyle=':', color='#d62728', label='点线') ax.set_xlabel('x') ax.set_ylabel('y') ax.legend() fig.tight_layout() plt.show()另一个实用技巧是使用fmt参数快速组合:ax.plot(x, y, 'r--')表示红色虚线。这个写法适合快速探索阶段,正式出图时我会写成带linestyle和color的完整形式,可读性更好。
2.2 线宽、标记点和透明度:细节决定质感
linewidth(缩写lw)控制线条粗细。这看起来是小事,但项目交付的图一旦投到大屏或者印到PDF里,太细的线会直接“消失”。我的经验值是:一般展示曲线用1.5~2,主视觉曲线用2.5,辅助参考线用0.8~1。数据量大的曲线如果线太粗,会糊成一团黑色,这时反而要降线宽,同时配合透明度。
标记点marker用于标出离散数据的位置,可选的形状很多:'o'圆点、's'方块、'^'三角、'd'菱形等。什么时候该用标记?我总结的规律是:点少(几十个以内)且需要精确读数值时,用标记;点多(几百上千)时,用线就够了,硬加标记只是一片墨点。折中方案是markevery参数,它控制每隔多少个点画一个标记:
x = np.linspace(0, 50, 200) y = np.random.randn(200).cumsum() fig, ax = plt.subplots(figsize=(8, 3.5)) ax.plot(x, y, linewidth=1.2, marker='o', markersize=5, markevery=20) plt.show()这样线是完整的,标记只是每隔20个点出现一次,既保留了线的连续感,又能看清楚取样位置。这是我用了几年之后才发现的参数,以前都是手动切片数据来实现,代码又长又容易错。
透明度用alpha控制,取值0到1之间,1为完全不透明。多条线叠在同一个坐标里时,把每条线的alpha都设成0.5~0.7,能明显减轻互相遮挡带来的视觉压力,还能让重叠区域的密度透出来,这对探索性数据分析图特别有用。
2.3 图例、坐标标签和标题:让线条能被读懂
线画出来只是半成品,没有图例、标签和标题的图,就算样式再精致别人也看不懂。图例要依赖label参数:每个plot()里传了label,最后调用一次ax.legend()才会显示图例。这个顺序经常有人搞反——画了线但不传label,然后问为什么图例是空的。
图例的位置和排列也有讲究。默认loc='best'是让Matplotlib自己找空白处,大多数情况靠谱,但有时它会挡住关键曲线,尤其是曲线密集的时候。我会用手动方式:ax.legend(loc='upper right'),或者干脆把图例放到图外面:
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), borderaxespad=0)这句配合fig.tight_layout()或bbox_inches='tight'保存时,图例会显示在图的右侧,不占绘图区域。多序列图例如果条目很多,用ncol=2或ncol=3排成两列三列,比一列到底清爽很多。
还有一个高阶技巧:用Line2D手造图例句柄,可以在图例里加入图上并不存在的数据线,用来表达“理想趋势”或“参考范围”这类概念:
from matplotlib.lines import Line2D legend_items = [ Line2D([0], [0], color='#1f77b4', lw=2, label='实测值'), Line2D([0], [0], color='#2ca02c', lw=2, ls='--', label='目标趋势') ] ax.legend(handles=legend_items, loc='best')3. 坐标轴控制:让线在正确的尺度上讲故事
3.1 对数坐标轴:什么时候用、为什么用、怎么用
「对数坐标轴」这个名字听起来吓人,其实想解决的事情很简单:当数据跨越好几个数量级时,线性轴会把小数值挤在底部,肉眼完全看不出结构。比如网络请求延迟从1ms到1000ms,价格从0.01到10000,神经网络训练早期loss从5.0降到0.005,这种数据用线性轴画,前段走势被压缩成一条陡降到地平线的曲线,后面几乎是一条平线,什么都看不清。
解决办法就是对数刻度。Matplotlib里用两行代码:
ax.set_xscale('log') ax.set_yscale('log')如果x和y都要对数,可以直接ax.loglog(x, y)。绝大多数情况下,我们更常遇到的是只对y轴做对数处理,比如观察loss下降、响应耗时分布,这时用ax.set_yscale('log')就够了。
对数坐标最大的隐藏坑是:数据必须全为正数。0和负数在数学上没法取对数,Matplotlib遇到会直接跳过或画不出期望的效果。如果数据里混了0值,先过滤:
x = np.array([0, 1, 2, 4, 8, 16, 32, 64]) y = np.array([0, 1, 4, 16, 64, 256, 1024, 4096]) mask = (x > 0) & (y > 0) ax.loglog(x[mask], y[mask], '-o')如果负值也必须展示(比如误差可以是负的),可以用symlog刻度,它在0附近用线性、两侧用对数:
ax.set_yscale('symlog', linthresh=1)对数坐标还有个很妙的理解:幂律关系y = C * x^k画在对数坐标下会变成一条直线。以前做性能分析时,我经常用loglog坐标确认数据是否符合幂律分布——只要点在双对数图上排成直线,斜率就是幂指数k,一眼就能读出规律,不需要拟合。
x = np.logspace(-2, 2, 100) y = 3 * x ** 1.5 fig, ax = plt.subplots(figsize=(8, 4)) ax.loglog(x, y, linewidth=2) ax.set_xlabel('x(对数坐标)') ax.set_ylabel('y(对数坐标)') ax.set_title('幂律关系在对数坐标下呈直线') plt.show()3.2 坐标范围与刻度:不要让默认值拖后腿
Matplotlib默认会根据数据范围自动设坐标轴边界,还会在两端各留5%的空白。大多数时候这很友好,但有几种情况要手动干预:想从0开始展示基线、想去掉空白让曲线占满画布、想锁定坐标范围方便多图对比。
ax.set_xlim(0, 100) ax.set_ylim(0, 1)多图对比时最怕每张图的坐标范围不一样,读者会误读曲线波动大小。如果是不同类别的数据做横向对比,一定要手动固定x轴范围;如果是同一接口不同时段的指标,y轴也最好统一。
刻度间隔方面,MultipleLocator是我最常用的工具:
from matplotlib.ticker import MultipleLocator ax.xaxis.set_major_locator(MultipleLocator(5)) # x轴主刻度每隔5显示一个 ax.yaxis.set_major_locator(MultipleLocator(0.2))配合FuncFormatter可以美化刻度标签,比如把y轴显示成百分比:
from matplotlib.ticker import PercentFormatter ax.yaxis.set_major_formatter(PercentFormatter(1))把刻度控制权从默认手里拿回来之后,整张图会一下子变得专业很多。
3.3 双Y轴:两种量纲同框要克制
有时候两条线的量纲完全不一样,比如“温度”和“湿度”,一个在0~40,一个在20%~80%,直接画在同一个y轴上必有一条被压平。这时可以用twinx()创建共享x轴的第二个y轴:
fig, ax1 = plt.subplots() t = np.arange(0, 24, 0.1) temp = 25 + 8 * np.sin(2 * np.pi * t / 24) humidity = 55 + 20 * np.sin(2 * np.pi * t / 24 + 2) ax1.plot(t, temp, color='tab:red', label='温度') ax1.set_ylabel('温度 (°C)', color='tab:red') ax1.tick_params(axis='y', labelcolor='tab:red') ax2 = ax1.twinx() ax2.plot(t, humidity, color='tab:blue', linestyle='--', label='湿度') ax2.set_ylabel('湿度 (%)', color='tab:blue') ax2.tick_params(axis='y', labelcolor='tab:blue') plt.show()注意双Y轴图有个天然视觉陷阱:两条线的交点会被读者误读成“数值相等”,但它们的刻度根本不通用。所以我在实际项目里对双Y轴非常克制,能用上下两个子图(同一个x轴)替代就尽量不用双Y轴,尤其当目标读者不熟悉这个图表的语境时。两条曲线的纵向对比关系,用共享x轴的两个独立坐标区来表达,更不容易产生误读。
4. 子图使用:一次生成多张“子线图”
4.1 六张图合在一个面上的核心方法
搜“六张图合在一个图上”这个需求的人,多半是想把多个相关的指标曲线汇总到一张大图里,方便整体对比。Matplotlib实现这点最直接的方式就是plt.subplots():
fig, axes = plt.subplots(2, 3, figsize=(15, 8))这句创建了一个2行3列的Axes数组,总共6个子图。axes的形状是(2, 3),通过axes[0, 0]、axes[1, 2]这样的索引可以精确定位每个子图。如果要批量设置,直接用axes.flat遍历所有子图:
for ax in axes.flat: ax.plot(x, y) ax.grid(alpha=0.3)当6个子图共享同一个x轴(比如都是时间序列)时,可以通过sharex=True省掉大量冗余的横轴刻度:
fig, axes = plt.subplots(2, 3, figsize=(15, 8), sharex=True)这样最后一行的子图会显示x轴刻度,上面一行的x轴自动隐藏,图面瞬间干净不少。同理,如果6个子图的y轴尺度一致,也可以sharey=True;如果不一致,千万别共享,否则数值差别大的指标会被压成一条直线。
4.2 同一坐标内叠加多条线
子图之外的另一种“多线”场景,是在同一个坐标区里叠加多条线,用于对比不同分组、不同时间窗口、不同模型输出的变化。最简单的写法是多次调用plot(),每次传不同label,最后统一legend()。但如果线条数量多到要靠循环生成,就要注意控制颜色循环和标签:
fig, ax = plt.subplots(figsize=(9, 5)) cols = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728'] for i, col in enumerate(cols): y = np.sin(x + i * 0.5) ax.plot(x, y, color=col, linewidth=1.5, alpha=0.8, label=f'相位偏移 {i * 0.5}') ax.legend(loc='upper right', ncol=2) plt.show()在循环里画线有几个心得。第一,不要每次都调用plt.plot()再plt.legend(),那样图例范围会越来越乱;正确做法是循环里只传label,循环结束后统一调用一次legend。第二,颜色尽量用色觉友好、色相差异大的组合,别用红绿这种色盲用户看起来几乎一样的搭配。第三,如果线实在太多,用alpha降透明度比硬塞不同颜色更实用。
4.3 布局与尺寸:别让子图挤成一团
六个子图画在同一张图上,最常遇到的问题就是“挤成一团”:标题和坐标标签互相重叠、日期刻度横竖交错。解决方案有三板斧。
第一是constrained_layout=True,在创建fig时直接设置,让Matplotlib自动为子图之间留出空间:
fig, axes = plt.subplots(2, 3, figsize=(15, 8), constrained_layout=True)第二是fig.tight_layout(),在画完内容后调用一次,自动调整子图间距。第三是fig.subplots_adjust(hspace=0.3, wspace=0.3),手动控制子图之间的垂直和水平间距。这三种方式不冲突,可以按场景选择。
保存时,bbox_inches='tight'是个必杀技。它会自动裁掉图片周围多余的空白,避免明明画了标题却因为边距太紧被截掉一半:
fig.savefig('six_panels.png', dpi=150, bbox_inches='tight')dpi参数决定图片清晰度,默认100偏糊,交付用的图我一般用150~300。如果后续要放大或出版印刷,还可以直接存成矢量格式fig.savefig('six_panels.svg'),无限放大都不会糊。
5. 中文显示、字体与全局配置
5.1 中文乱码:几行代码解决的事
Matplotlib画图遇到中文,默认情况下是一排方格子,这是新手最常遇到的劝退问题。原因是Matplotlib的默认字体DejaVu Sans不支持中文字形,遇到中文只能渲染成占位方块。解决方案很明确:手动指定一个系统中文字体。
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'Noto Sans CJK SC'] plt.rcParams['axes.unicode_minus'] = False第一行指定字体候选列表,Matplotlib会从左到右找第一个系统里存在的字体。Windows一般有SimHei(黑体)和Microsoft YaHei(微软雅黑);macOS和Linux常见的是Noto Sans CJK SC或Source Han Sans SC。如果你的脚本要跨系统跑,直接把这几个都写进列表。
第二行axes.unicode_minus = False解决的是另一个隐藏问题:设置中文字体后,数值轴的负号偶尔会变成方块,这是因为默认负号字符用的也是DejaVu字体。关掉unicode_minus后,负号会用ASCII风格的减号渲染,干干净净。
如果你不确定系统里有哪些可用字体,可以列出来看:
from matplotlib import font_manager names = sorted({f.name for f in font_manager.fontManager.ttflist}) print([n for n in names if 'Hei' in n or 'YaHei' in n or 'CJK' in n or 'SimSun' in n])5.2 全局配置rcParams:一次设置,处处生效
中文字体只是rcParams这个全局配置字典里的一个字段。实际项目里我更推荐把所有通用配置集中到一个代码块,画每张图之前先执行一次,直接形成统一的风格基调:
plt.rcParams.update({ 'figure.figsize': (10, 5), 'figure.dpi': 100, 'savefig.dpi': 150, 'font.sans-serif': ['Microsoft YaHei', 'SimHei', 'Noto Sans CJK SC'], 'axes.unicode_minus': False, 'axes.grid': True, 'grid.alpha': 0.3, 'lines.linewidth': 1.8, 'legend.fontsize': 10, 'axes.titlesize': 13, 'axes.labelsize': 11, })这几个字段覆盖了最常用的需求:图片尺寸、保存清晰度、中文字体、网格线、线条默认粗细、字号。配置一次之后,后面所有画的图都继承这些设置,不用每张图重复写。如果某张图想临时覆盖,单图内显式传参就行,优先级高于全局配置。
这里有个实操中的坑要注意:rcParams修改的是当前代码进程里的全局状态。Notebook里如果反复执行不同配置的代码块,后执行的会覆盖先执行的,容易让前面的图“风格突变”。我的习惯是每个Notebook开头只做一次配置,后续所有绘图逻辑都依赖这同一份配置。
5.3 样式表:一行代码切换整体风格
除了手动改rcParams,Matplotlib还内置了多套样式表,一行代码就能切换整体外观:
plt.style.use('ggplot')常用样式包括:'ggplot'模仿R语言ggplot2风格、'bmh'来自Bayes Methods Hackathon、'fivethirtyeight'新闻数据风格、'seaborn-v0_8-whitegrid'浅灰网格风格。注意Matplotlib 3.6之后,seaborn样式统一改名成seaborn-v0_8-*,直接写'seaborn'会飘红甚至报错。
样式表在Notebook里更适合用上下文管理器做局部生效,不影响后续画图:
with plt.style.context('ggplot'): fig, ax = plt.subplots() ax.plot(x, y) plt.show()6. 实战演练:从原始数据到可发布的线图
6.1 先造一份“像真实业务”的模拟数据
空讲参数不够直观,我模拟一份内容平台的运营数据,用6个指标分别展示不同类型的线图。时间跨度120天,包含DAU、新增用户、内容发布量、评论量、接口P95耗时和模型训练loss。
import numpy as np import pandas as pd import matplotlib.pyplot as plt np.random.seed(42) days = pd.date_range('2024-01-01', periods=120, freq='D') df = pd.DataFrame(index=days) # DAU:线性上升 + 7天周期性波动 + 噪声 df['dau'] = 10000 + 60 * np.arange(120) + 1500 * np.sin(2 * np.pi * np.arange(120) / 7) + np.random.normal(0, 200, 120) # 新增用户:平稳上升 df['new_user'] = 300 + 2.5 * np.arange(120) + np.random.normal(0, 20, 120) # 内容发布量:随机波动,再做7日移动平均 publish = np.random.randint(800, 1500, 120) df['publish'] = publish df['publish_ma7'] = pd.Series(publish).rolling(7, min_periods=1).mean() # 评论量:工作日和周末差异明显 weekend = (days.dayofweek >= 5).astype(int) df['comment'] = 5000 + 3000 * weekend + np.random.normal(0, 300, 120) # P95耗时:逐渐劣化,适合用对数轴观察 df['p95'] = 80 + 8 * np.arange(120) ** 1.5 / 20 + np.random.normal(0, 5, 120) # 训练loss:模拟指数衰减 df['loss'] = 3.0 * np.exp(-0.04 * np.arange(120)) + 0.005数据生成逻辑里我刻意安排了不同形态:有的是线性趋势叠加周期,有的是随机毛刺,有的是指数衰减。这样画出来的6张子图才能覆盖多种线条样式的使用场景。
6.2 一张大图覆盖多种线条形态
下面这段代码把6个指标用2行3列子图汇总到一张图上,每张子图用不同的线型、标记和坐标轴设置,同时处理了中文显示、日期倾斜、对数轴和全局网格:
fig, axes = plt.subplots(2, 3, figsize=(16, 8), constrained_layout=True) axes[0, 0].plot(df.index, df['dau'], linewidth=1.6, color='#1f77b4') axes[0, 0].set_title('DAU 趋势') axes[0, 1].plot(df.index, df['new_user'], linestyle='--', marker='o', markersize=4, markevery=10, linewidth=1.4, color='#ff7f0e') axes[0, 1].set_title('新增用户') axes[0, 2].plot(df.index, df['publish_ma7'], linestyle='-.', linewidth=1.6, color='#2ca02c') axes[0, 2].set_title('内容发布量 7 日均线') axes[1, 0].plot(df.index, df['comment'], alpha=0.75, linewidth=1.2, color='#d62728') std = df['comment'].std() axes[1, 0].fill_between(df.index, df['comment'] - std, df['comment'] + std, alpha=0.15, color='#d62728') axes[1, 0].set_title('评论量及波动区间') axes[1, 1].plot(df.index, df['p95'], linewidth=1.5, color='#9467bd') axes[1, 1].set_yscale('log') axes[1, 1].set_title('接口 P95 耗时(对数 Y 轴)') axes[1, 2].plot(df.index, df['loss'], linewidth=1.8, color='#8c564b') axes[1, 2].set_yscale('log') axes[1, 2].set_title('训练 Loss(对数 Y 轴)') for ax in axes.flat: ax.tick_params(axis='x', rotation=30) ax.grid(alpha=0.3) fig.suptitle('内容平台核心指标线图汇总', fontsize=16) fig.savefig('core_metrics.png', dpi=150, bbox_inches='tight')这段代码值得注意的地方有几个。markevery=10让新增用户的标记不会糊成一团;评论量那张图用fill_between画出了波动区间;两处对数轴分别用在耗时和loss上,因为这两个指标动态范围大、线性轴看不清楚。最后统一grid和日期旋转,整张图的风格就整齐了。
6.3 与scikit-learn联动:画模型的学习曲线
绘图线不只用在业务指标上,模型评估里也很常见。比如scikit-learn的learning_curve可以返回不同训练集大小下的训练分数和验证分数,把这几个数组画出来,就能直观判断模型是过拟合还是欠拟合。
from sklearn.datasets import make_regression from sklearn.model_selection import learning_curve from sklearn.ensemble import RandomForestRegressor X, y = make_regression(n_samples=500, n_features=8, noise=0.3, random_state=42) train_sizes, train_scores, valid_scores = learning_curve( RandomForestRegressor(n_estimators=100, random_state=42), X, y, train_sizes=np.linspace(0.1, 1.0, 8), cv=5, scoring='r2' ) fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(train_sizes, train_scores.mean(axis=1), 'o-', label='训练集得分') ax.plot(train_sizes, valid_scores.mean(axis=1), 's--', label='验证集得分') ax.fill_between(train_sizes, valid_scores.mean(axis=1) - valid_scores.std(axis=1), valid_scores.mean(axis=1) + valid_scores.std(axis=1), alpha=0.2) ax.set_xlabel('训练样本数') ax.set_ylabel('R² 得分') ax.set_title('随机森林回归的学习曲线') ax.legend() fig.tight_layout() plt.show()这里线条的作用一眼就能看出来:训练集和验证集两条线的距离、走势,以及验证曲线的波动带,都在传达模型泛化能力的信息。画这种图时,'o-'和's--'这种带标记的组合特别合适,因为样本点本身就代表“训练了几次”,不是连续过程,标记点能帮读者精确对应横坐标。
7. 常见问题与排查技巧
7.1 高频问题速查表
画线过程里我踩过的坑、读者问过我的问题,整理成一张表,遇到问题时按图索骥。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 图上没有线 | 数据里有NaN,或x/y长度不一致 | 用np.isnan()检查数据,过滤或填充NaN |
| 图例不显示 | 每个plot没传label | 每个plot传入label,最后调一次ax.legend() |
| 中文显示为方块 | 默认字体不含中文字形 | 设置plt.rcParams['font.sans-serif']为系统中文字体 |
| 负号显示为方块 | 中文字体设置后负号字符异常 | 同时设axes.unicode_minus=False |
| 保存的图很模糊 | dpi太低 | savefig(dpi=150)或dpi=300 |
| 图的标题/坐标标签被截断 | 边距不够 | savefig时加bbox_inches='tight' |
| 多个子图重叠 | 子图间距没调整 | 用constrained_layout=True或tight_layout() |
| 对数轴上有0值/负值 | log定义域不允许非正数 | 过滤非正数,或改用symlog |
| 日期刻度挤成一团 | x轴是字符串类型 | 转成datetime后设置刻度角度 |
7.2 我踩过的几个坑和现在的处理习惯
第一个坑是我刚开始用循环画图时踩的。那时候在for循环里直接写plt.plot(),又写plt.xlabel(),结果所有线都画到了同一张图上,标题还一个盖一个。现在我的习惯是每次循环开始前创建独立的fig, ax = plt.subplots(),循环里只用ax.xxx(),循环结束后统一设置标题和保存,顺序清晰、完全不会串图。
第二个坑和时间序列有关。早期我拿着订单数据的日期列直接画,横坐标确实显示出日期,但刻度密到粘连,整条x轴像一坨黑色横杠。后面改成pd.to_datetime()转类型,再用ax.tick_params(axis='x', rotation=30)把刻度旋转30度,才彻底解决。
第三个坑更隐蔽,发生在Notebook里。同一个cell重复执行了多次plt.plot(),结果旧图一直“残留”在内存里,画出来的图看起来好像数据变了但像素没刷新。如果遇到这种诡异情况,通常是执行环境缓存了之前的绘图状态,手动plt.close(fig)或者从头重启内核就能解决。
另外想特别提醒一句:画完图,尤其是给同事或客户看的交付图,一定要主动savefig输出成PNG或PDF文件,而不是只在Notebook里show()一下。Notebook里的图渲染效果和最终文件经常有细微差别,只有保存出来的文件才是别人真正看到的东西。我现在的固定流程是:数据整理完,先固定在代码开头统一配置rcParams,再按“创建fig → 画线 → 设标签 → 调图例 → 保存高dpi”的顺序来,跑完不会漏掉任何一张图。
Matplotlib画线的这些年,我最大的体会是:图是给人看的,不是给代码跑的。线型、颜色、坐标尺度、图例摆放,每一个细节都在影响读者读取信息的效率。与其指望默认设置,不如自己把关键参数掌握住,哪怕只是记住markevery、set_yscale('log')、bbox_inches='tight'这三个小东西,出图质量也能立刻上一个台阶。