1. 先分清图是给谁看的:机器学习工作流里可视化的三种真实用途
先说一个我印象很深的场景。某次模型评审会,一位同事投出四张图:一张 ROC 曲线几乎贴着左上角,一张混淆矩阵配色鲜艳,一张特征重要性柱状图,还有一张训练损失曲线平滑下降。会议室里大家点头,结论是"可以上线"。两周后,线上指标掉了将近一半。回头复盘时才发现,我在那四张图里根本找不到任何能定位问题的信息——没有目标分布、没有特征与标签的分组对比、没有时间维度上的效果漂移图。这件事之后我调整了自己的习惯:先定义这张图要回答什么问题,再决定画什么,而不是先打开 matplotlib 再说。
数据分析与图表绘制在机器学习项目里从来不是"最后交个报告"的收尾动作,它是贯穿全流程的观察手段。它的核心价值在于把抽象的数字矩阵转换成肉眼能判断形状、异常、趋势和分界线的图形,让你在做特征工程、调参、模型选型这些决策时,有依据而不是凭感觉。适合谁看?我觉得三类人都该把这块练熟:做建模的人需要它定位问题,做数据的人需要它验证假设,做决策的人需要它做取舍。下面的内容我会按真实工作流的顺序展开,从清洗、探索、相关性、训练过程、评估图表一路走到工程化落地,中间穿插我自己踩过的坑和能直接抄的代码片段。
1.1 给自己看的探索图:容忍丑陋,追求信息密度
自用探索图的第一原则是别开美颜。我见过不少人一上手 EDA 就开始调 figsize、改配色、加标题,半小时过去图确实好看,但什么结论都没得出。你自己看的图,只需要满足两个条件:信息在多、迭代快。所以我的习惯是写一个plot_quick_overview(df)之类的函数,一次性把df.describe()、缺失率条形图、每个数值列的直方图、每个类别列的计数柱状图全部塞进一张大画布,figsize=(16, 20)也不要紧,看着丑没关系,关键是扫一眼就能发现"哦这列 80% 是空值""这列明显双峰""这列有一个 999999 的野值"。
这里有个成本意识的问题。一千万行的 DataFrame 直接plt.scatter会卡住你的机器几分钟,而且画出来的图是一坨实心色块,没有任何分析价值。所以探索阶段我几乎总在做两件事:抽样和聚合。抽样就随机取几万行看看形状,聚合就用hexbin、二维直方图或者分组统计替代逐点绘制。这两招在后面的章节我会给具体代码。
提示:探索图不要覆盖保存,用
plt.show()看完就丢。真正值得留档的图,是你能用它讲出结论的那种,通常不超过十张。
1.2 给同事看的诊断图:必须能定位到具体切片
第二类图是发给协作方的,比如"这个模型在某个子群上效果特别差,你看看是不是特征有问题"。这类图的要求和自用图完全不同:它必须自带上下文,别人不需要问你就能看懂。具体来说,至少要包含数据范围、样本量、对比基线这三样东西。
举个具体的例子。你说"这列特征分布有点偏",对方没法判断严重程度。但如果你画的是训练集和验证集上这一列的分位数箱线图并排对比,还标上各自样本量,对方一眼就能看出"验证集这一列的上四分位比训练集高出一截,说明数据切分有时间上的错位"。诊断图的价值不在于好看,而在于它把一次沟通从三轮对话压缩成一眼。我自己的经验是,凡是要发给别人的图,我都会在标题里写清楚"什么数据、什么条件、什么单位",在备注里写清楚样本量,这个习惯省下的沟通时间远超写标题的几秒钟。
1.3 给决策方看的汇报图:删掉一切不承载信息的元素
第三类是汇报图,也是最容易被做坏的一类。很多人把诊断图直接搬进汇报材料,结果一页 PPT 上挤着六条曲线、十二个图例、三种颜色,听众的注意力根本落不到结论上。汇报图的逻辑是一张图讲一件事,凡是不能支撑这件事的线条、网格、刻度、图例,全部删掉。
我一般的做法是:从诊断图里挑出最关键的那一条趋势或那一组对比,把它的字号放大到能在投影上看清,把其他东西淡化成浅灰或直接去掉。比如"模型在长尾类目上的召回率只有 0.31,而头部类目有 0.86"这件事,一张按类目排序的横向柱状图就够了,不需要 ROC 也不需要混淆矩阵。千万不要在汇报图上放 ROC 曲线——我试过,非技术背景的听众几乎都会问"这条线下面的面积是干什么的",然后话题就跑偏了。汇报图要的是结论,不是证据链的完整呈现。
2. 数据进图之前的清洗动作,决定了图到底可不可信
我有个很固执的习惯:任何图在画之前,先跑一遍数据体检。因为图是数据的投影,数据有毛病,图会把毛病放大而不是掩盖。一张漂亮的直方图如果建立在"把缺失值填成 0"的基础上,那它的形状基本是骗人的;一张相关性热力图如果没处理异常值,那几个极值点会把整张矩阵的颜色全带偏。这一章讲的就是进图之前必须做完的几件事,以及为什么。
2.1 缺失值、异常值、量纲:三件必须先处理的事
缺失值先看分布,不要先看总量。总量只告诉你"这列缺了 30%",但缺失的位置信息量更大:如果某一列的缺失集中在某个时间段或者某个渠道,那它本身就是个强特征,直接 fillna 会把信号抹掉。我的做法是画一张缺失模式的矩阵图——按行取前几百行、按列排列,缺失处涂色。这张图能立刻暴露"某几列总是同时缺失"这种规律,通常意味着上游埋点改了或者某个数据源断了。
异常值不要用"3 倍标准差之外一律删掉"这种粗暴规则。真实业务数据里,异常值有两种:一种是录入错误,比如年龄 200、金额 -1;另一种是真实的长尾,比如大客户的单笔订单金额。前者该修,后者绝对不能删,删掉就是把最有价值的那部分样本扔了。判断办法很简单:把那几十个异常值单独拎出来看一下其他字段,如果是同一条记录的多个字段都合理(比如金额很大但商品数量也大),大概率是真实长尾。
量纲问题专门说一下,因为它直接影响多变量图的解读。一个取值范围 0 到 1 的特征和一个取值 0 到 100000 的特征放在同一张图或同一个相关性矩阵里,后者的视觉权重会压倒前者。做 EDA 的时候,对金额、计数这类量纲跨度大的列,我习惯同时画原始尺度和对数尺度两张图,很多时候对数尺度下的分布才看得清形状。
2.2 用 describe 和 dtype 做一次 thirty 秒体检
这段代码我几乎每个新数据集都会跑一遍,写成一个函数反复用:
import numpy as np import pandas as pd def quick_profile(df: pd.DataFrame) -> pd.DataFrame: num_cols = df.select_dtypes(include=np.number).columns rows = [] for col in df.columns: s = df[col] row = { "dtype": str(s.dtype), "n_missing": int(s.isna().sum()), "missing_pct": round(s.isna().mean() * 100, 2), "n_unique": int(s.nunique(dropna=True)), } if col in num_cols: row.update({ "min": s.min(), "p50": s.median(), "max": s.max(), "mean": round(s.mean(), 4) if s.notna().any() else np.nan, "std": round(s.std(), 4) if s.notna().any() else np.nan, }) else: vc = s.astype("object").value_counts(dropna=True) row["top_value"] = vc.index[0] if len(vc) else None row["top_ratio"] = round(vc.iloc[0] / max(len(s.dropna()), 1), 3) if len(vc) else np.nan rows.append({"col": col, **row}) return pd.DataFrame(rows).set_index("col") profile = quick_profile(df) print(profile.sort_values("missing_pct", ascending=False).head(20))这份输出能直接回答四个问题:哪些列缺失严重、哪些列看着是数值其实是编码过的类别(n_unique很小就说明这一点)、哪些列的最大值离谱、哪些类别列被单一取值霸占(top_ratio接近 1,这种特征对模型基本没有区分度)。
我踩过最深的坑是数值列存成字符串。有一次金额列里混进了一个 "N/A" 字符串,整列 dtype 就变成了 object,describe()直接跳过它,我在画直方图时才报错。所以体检的时候一定要显式检查 dtype,别假设列名像金额就一定是浮点数。
2.3 抽样与聚合:别把百万行直接怼进散点图
大数据集绘图的核心矛盾是:像素数量是固定的,数据点数量不是。一张 1000×800 的画布最多也就几十万个像素,你往上画一百万点,等于把大量点叠在同一个位置,最后看到的是一团色块,而且是"点越密颜色越深"这种半随机的观感,完全没有分析意义。
我常用的三种降载手段,各有适用场景:
| 手段 | 适用场景 | 具体做法 | 代价 |
|---|---|---|---|
| 随机抽样 | 看整体形状、找异常点 | df.sample(n=20000, random_state=42) | 稀有类别可能被抽没 |
| 分层抽样 | 类别不均衡的分类任务 | 按标签或关键类别分组后各组抽固定数量 | 需要多写几行代码 |
| 二维聚合 | 看密度分布 | hexbin、二维直方图、分组统计 | 丢失单点信息 |
随机抽样有个陷阱:如果目标变量极度不均衡(正样本占 0.3%),抽两万行可能只有六十个正样本,画出来的分布图和真实分布差别很大。这种情况我会改用分层抽样,或者干脆对正负样本各抽一批然后分颜色叠画。二维聚合我更偏爱hexbin,因为它带颜色映射,密度差异一目了然,比二维直方图在视觉上更容易读。
import matplotlib.pyplot as plt fig, ax = plt.subplots(figsize=(7, 5)) hb = ax.hexbin(sample_df["feat_a"], sample_df["feat_b"], gridsize=60, bins="log", cmap="viridis", mincnt=1) ax.set_xlabel("feat_a") ax.set_ylabel("feat_b") ax.set_title(f"feat_a vs feat_b density (n={len(sample_df)})") fig.colorbar(hb, ax=ax, label="log10(count)") plt.tight_layout() plt.show()注意bins="log"这个参数,不加它的话,大部分区域的色阶都会被中心那几块超高密度的六边形吃掉,边缘的稀疏结构完全看不见。这是我在画密度图时最常调整的一个参数。
3. 单变量与多变量分布图:把数据形状看透的几种画法
分布图是 EDA 的地基。很多建模阶段的困惑,比如"为什么这个特征在模型里重要性排第一但业务上说不通",答案往往藏在一张没画过的分布图里——也许这一列是双峰分布,而双峰的成因是两个完全不同的数据来源被合到了一起。这一章讲单变量和多变量的分布怎么看,重点讲最容易被忽略的那些参数。
3.1 直方图与核密度:分箱数量是最容易出错的地方
直方图最大的坑是分箱数。默认bins=10在几千行数据上通常够用,但在几万行以上就太粗了,很多结构会被抹平;反过来分箱太多,图就变成一片锯齿,噪音盖过信号。我的经验规则是这样:先用默认参数看一眼整体,再切换成bins="auto"(底层走的是基于四分位距的规则)和bins="fd"(Freedman-Diaconis 规则)各看一次,如果这三种画出来的形状差异很大,说明数据本身对分箱敏感,那就要警惕"结论是被分箱选出来的"。
一个简单的经验公式是分箱数取样本量的平方根附近,一万行就是 100 个箱左右。但类别不均衡的数据要特别注意:如果某一类只占 2%,分 100 个箱之后每个箱里可能一个点都没有,直方图会出现大片空白。这时候我推荐直接改用密度归一化的画法(density=True)或者核密度估计,它不依赖分箱,形状更连续。
fig, axes = plt.subplots(1, 3, figsize=(15, 4)) for ax, bins in zip(axes, [20, "auto", "fd"]): ax.hist(df["amount"].dropna(), bins=bins, density=True, alpha=0.6, edgecolor="white", linewidth=0.5) ax.set_title(f"bins={bins}") ax.set_xlabel("amount") plt.tight_layout() plt.show()核密度估计(KDE)的参数是带宽bw_adjust。带宽太小,曲线会跟着每个样本抖,看起来满是小尖峰;带宽太大,双峰会被抹成单峰。我会在0.5到2之间试几个值,如果某个双峰在不同带宽下都稳定存在,那它就是真实结构;如果只在极小带宽下出现,那多半是噪声。
提示:做直方图前先确认没有极值。一个 1e12 的野值会让 x 轴拉长到其他数据全挤在左边一个像素里,看起来像"数据都集中在 0 附近",其实是坐标轴被绑架了。先
query掉极端值,或者直接设xlim到 1% 到 99% 分位。
3.2 箱线图与小提琴图的适用边界
箱线图的信息量很集中:中位数、四分位、异常点。但它有一个硬伤,就是只能看到五个统计量,看不出分布形状。双峰分布在箱线图上完全看不出来。所以我的一般规则是:看组间对比用箱线图,看分布形状用小提琴图。
小提琴图本质是核密度估计加镜像对称,它把形状和分位数都保留了。但小提琴图在样本量小的时候很不靠谱,几十个点拟合出来的密度曲线基本是随机形状,这时候老老实实用箱线图加散点叠加(stripplot)反而更诚实。我会在组内样本量低于一百的时候一律加散点,让读者自己判断。
还有一个细节:箱线图的默认须(whisker)是 1.5 倍四分位距,超出范围的点被标成异常点。这个 1.5 是个约定俗成的经验值,不代表所有业务都适用。金额类数据里,超出 1.5 倍 IQR 的点往往是真实的高价值用户,把它们全标成"异常"会误导判断。遇到这种分布,我会改成whis=(5, 95),让须覆盖到 5% 和 95% 分位,剩下的点就不画成异常标记了。
3.3 散点图、hexbin 与二维密度图
两个数值特征的关系,第一反应是散点图。但真正能读的散点图有几个前提:样本量别太大、点要半透明、要防止过度重叠。alpha=0.3是常见起点,样本量上万的时候即使半透明也会糊成一片,就用前面说的hexbin。
散点图里我最常做的一个增强是按标签分组着色。同样一组点,如果按类别染色之后呈现出明显的分离趋势,这个特征对分类模型就是有用的;如果两类点完全混在一起,那这个特征大概率贡献不了什么。这一步实际上是在用眼睛做一次粗略的特征筛选,比看特征重要性排行榜更直观,因为它能告诉你"这两个特征组合起来有没有区分度",而重要性榜单只给单变量分数。
fig, ax = plt.subplots(figsize=(7, 6)) for label, g in df.groupby("target"): ax.scatter(g["feat_a"], g["feat_b"], s=8, alpha=0.35, label=f"target={label}") ax.legend(markerscale=3) ax.set_xlabel("feat_a") ax.set_ylabel("feat_b") plt.tight_layout() plt.show()3.4 分类变量的处理:排序和横向布局
分类变量的绘图问题主要是可读性。几十个类别塞进一张竖直柱状图,标签会全都挤成黑条,旋转 45 度也救不回来。我的标准做法只有两条:横向柱状图 + 按数值排序。横向布局让类别名水平排列,不用旋转;排序让读者一眼看到头部和尾部,而不是在无序的柱子之间来回找。
饼图我基本不用。人的视觉系统比较角度和弧长的精度远低于比较长度,类别一多就完全读不出来。如果确实要表达"占比",我用堆叠横向条形图,或者干脆在柱状图的柱子上标注百分比——数字比面积更准确。
4. 相关性、特征筛选与图表的联动:从图形反推建模决策
特征筛选这一步,很多人的做法是直接丢给模型去算重要性,或者跑一个相关系数矩阵看一眼。这两种做法都会漏掉东西。相关系数矩阵只能反映线性关系,模型重要性只反映该模型在特定特征组合下的贡献度,两者的结论经常不一致。这一章我想讲的是一种交叉验证的观察方式:图形负责提出假设,统计量负责验证假设。
4.1 相关系数矩阵的配色与系数选择
相关性热力图几乎人人都会画,但有两个细节经常被忽略。第一个是配色必须发散型且以 0 为中心。用顺序色阶(比如浅蓝到深蓝)画相关系数矩阵是错的,因为相关系数有正有负,负相关在顺序色阶里会被压成最浅色,看起来像"没有关系",实际上它可能是 -0.8 的强负相关。标准做法是用coolwarm或RdBu_r这类以白色为中间色的发散色阶,并且显式设vmin=-1, vmax=1。
第二个细节是皮尔逊和斯皮尔曼要分开看。皮尔逊只看线性关系,一个完美的 U 型关系在它眼里相关系数接近 0,但实际依赖关系极强。斯皮尔曼基于秩,能捕捉单调非线性关系,但仍然看不出 U 型。所以我画相关矩阵的时候,会同时看这两个,如果两者差异很大(比如皮尔逊 0.05、斯皮尔曼 0.6),这基本就是在喊"这里有个非线性关系,去画个散点图看看"。
import seaborn as sns corr_p = df[num_cols].corr(method="pearson") corr_s = df[num_cols].corr(method="spearman") fig, axes = plt.subplots(1, 2, figsize=(16, 7)) for ax, c, title in zip(axes, [corr_p, corr_s], ["Pearson", "Spearman"]): sns.heatmap(c, ax=ax, cmap="coolwarm", center=0, vmin=-1, vmax=1, square=True, linewidths=0.4, cbar_kws={"shrink": 0.7}) ax.set_title(title) plt.tight_layout() plt.show()还有一个实用技巧:相关性矩阵的绝对差值矩阵。把两个矩阵相减取绝对值,找出差值最大的那几对特征,它们就是最值得单独画散点图考察的对象。这个思路比逐个盯着矩阵格子效率高得多。
4.2 特征与标签的关系:分组均值图和条件分布
分类任务里,我必画的一张图是"每个特征按标签分组的分布"。方法可以是分组箱线图、分组小提琴图,也可以是简单的分组均值加点图。这张图能回答一个关键问题:这个特征在不同类别下的分布有没有错位。完全重叠的特征,无论模型怎么训都难有贡献;明显错位的特征,就是候选的主力特征。
但有一个特别容易踩的坑:分组均值图会把不均衡和量级混在一起。如果某一类只有 30 个样本,它组的均值噪声会非常大,画出来看着与别组差异明显,其实是抽样波动。所以我在分组图旁边一定会标注每组样本量,样本量小的组用虚线或浅色区分开。
回归任务里对应的图是"目标值和特征值的散点加平滑趋势线"。我一般用分箱后取均值再连线的方式画趋势,比直接拟合多项式更稳健,也不会被极端值带偏。分箱数取 10 到 20 之间,箱内样本量不足就合并相邻箱。
4.3 目标泄漏的视觉线索
这是我最想强调的一点。目标泄漏是机器学习里最致命也最隐蔽的问题,一旦发生,验证集指标会好得不真实,上线就崩。图表是发现泄漏最快的工具之一,有几个非常典型的视觉线索:
第一个线索是相关系数异常高。某个特征与目标的相关系数达到 0.95 以上,而业务逻辑上它不应该是决定性的,这时候必须查清楚这个特征的来源。我见过一个案例,某个"近三十天活跃天数"特征与是否流失的相关系数高达 0.97,一查发现这个特征是在用户已经流失之后才停止更新的,等于把答案写在了特征里。
第二个线索是特征分布在不同集合上出现断层,比如训练集和验证集这一列的取值区间完全不重叠。
第三个线索是某个特征的重要性断层式领先,第二名的十分之一都不到。这种情形下,除非业务上有强解释,否则我默认它是可疑的。
注意:发现可疑特征后不要直接删掉它,先查数据生成链路。有很多时候它是上游埋点重复写入造成的,修复之后特征其实很有效。
5. 训练过程可视化:让模型训练"看得见"
模型训练过程中产生的那一堆数值,如果只看最终一次打印的结果,你基本无法判断发生了什么。损失从 0.8 降到 0.3,是正常收敛还是过拟合前的最后挣扎?这个问题只有曲线能回答。这一章讲训练过程相关的几张图,以及在什么情况下要看哪一张。
5.1 学习曲线与验证曲线的读法
学习曲线横轴是训练样本量,纵轴是评分,两条线分别是训练集得分和验证集得分。它的读法很固定,但很多人记混:
| 曲线形态 | 诊断结论 | 应对方向 |
|---|---|---|
| 两条线都低且靠得很近 | 欠拟合,模型容量不足 | 增加特征、换更强模型、减少正则 |
| 训练高、验证低,差距大 | 过拟合 | 增加数据、加正则、简化模型 |
| 训练高、验证也高且贴近 | 数据量足够,模型合适 | 保持,考虑继续调参 |
| 验证曲线还在上升 | 数据量可能仍不足 | 继续加样本收益明显 |
验证曲线横轴是某个超参数的取值,纵轴是评分。它最直接的用途是找最优参数区间,但我更看重的是曲线的形状——如果曲线在某个区间内剧烈震荡,说明这个参数对结果非常敏感,选点附近的值不一定可靠;如果是一段平滑的平台,那平台中央附近选值最稳。这个观察对线上稳定性影响很大,我见过因为学习率选在震荡区导致每次重训结果差异明显的案例。
from sklearn.model_selection import learning_curve import numpy as np train_sizes, train_scores, valid_scores = learning_curve( estimator=model, X=X, y=y, cv=5, scoring="roc_auc", train_sizes=np.linspace(0.1, 1.0, 8), n_jobs=-1, shuffle=True, random_state=42, ) tr_mean, tr_std = train_scores.mean(1), train_scores.std(1) va_mean, va_std = valid_scores.mean(1), valid_scores.std(1) fig, ax = plt.subplots(figsize=(7, 5)) ax.plot(train_sizes, tr_mean, "o-", label="train") ax.fill_between(train_sizes, tr_mean - tr_std, tr_mean + tr_std, alpha=0.15) ax.plot(train_sizes, va_mean, "s-", label="validation") ax.fill_between(train_sizes, va_mean - va_std, va_mean + va_std, alpha=0.15) ax.set_xlabel("training samples") ax.set_ylabel("AUC") ax.legend() plt.tight_layout() plt.show()这里shuffle=True必须加,否则如果原始数据是按时间或其他顺序排列的,前置切片会只包含某一类样本,曲线会呈现出完全不真实的形态。这个坑我在时间序列数据上踩过一次,当时学习曲线显示"数据越多效果越差",排查了半天才发现是没打乱。
5.2 过拟合与欠拟合在曲线上的具体样子
只看最终的 train/valid 分数差,判断过拟合是可以的,但看不出过拟合发生的时机。所以我习惯在每一轮记录训练集和验证集的指标,然后画双曲线。典型形态有三种:一种是从头到尾两条线都贴在一起,差距始终很小,说明模型容量偏小;一种是早期贴在一起、后期验证线开始掉头向上(针对损失),这是典型的过拟合起点,早停点就该取在掉头的拐点附近几个轮次;还有一种是验证线从一开始就抖得厉害,说明批大小太小或者学习率偏大。
分类任务的曲线有个特点:验证损失和验证准确率的拐点不一定同步。我一般以验证损失为主参考,因为损失是连续量,对变化更敏感;准确率是离散化之后的量,噪声大。这两条曲线我都画,但从损失上做早停决策。
5.3 超参数搜索过程的可视化
网格搜索或者随机搜索出来的结果,如果只看"最优参数字典",你会错过参数之间的交互信息。我的做法是把结果整理成一个 DataFrame,画两张图:一张是参数取值的边际分布(横轴参数值、纵轴评分的小提琴图或箱线图),另一张是二维热力图(两个参数为轴、颜色为评分)。
第二张图特别有用。它能看出某些参数组合是"协同"的,比如学习率小配合树深度大效果更好,而单独看任何一个参数的边际分布都体现不出这一点。我通常对评分做排序,取前 20% 的结果画散点矩阵,比看完整热力图更聚焦。
6. 模型评估图表:混淆矩阵、ROC、PR 与阈值取舍
评估阶段的图是全部图里被误用最严重的一类。ROC 曲线漂亮地贴在左上角、AUC 0.95,听起来很好,但如果正样本只占 1%,这个模型可能一个正样本都没抓对。这一章讲三种核心评估图的正确用法,以及各自在什么条件下会骗人。
6.1 混淆矩阵:归一化方式决定结论
混淆矩阵是最基础的评估图,但它有三种归一化方式,含义完全不同:按真实类别归一化(行归一化,看的是每类的召回率)、按预测类别归一化(列归一化,看的是每类的精确率)、按总量归一化(看整体占比)。不写清楚用的哪种,这张图基本无法解读。
我的习惯是画两张并排的:左边行归一化看召回,右边列归一化看精确。这样能立刻定位问题——比如"长尾类召回很高但精确很低",说明模型把这个类预测得过于激进,任何有一点迹象的样本都被判进去了。
from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt cm_recall = confusion_matrix(y_true, y_pred, normalize="true") cm_prec = confusion_matrix(y_true, y_pred, normalize="pred") fig, axes = plt.subplots(1, 2, figsize=(12, 5)) for ax, cm, title, label in zip( axes, [cm_recall, cm_prec], ["normalized by true (recall)", "normalized by pred (precision)"], ["recall", "precision"] ): im = ax.imshow(cm, cmap="Blues", vmin=0, vmax=1) ax.set_title(title) ax.set_xlabel("predicted") ax.set_ylabel("actual") for i in range(cm.shape[0]): for j in range(cm.shape[1]): ax.text(j, i, f"{cm[i, j]:.2f}", ha="center", va="center", color="black" if cm[i, j] < 0.6 else "white", fontsize=9) fig.colorbar(im, ax=axes, shrink=0.8) plt.show()配色我用单色的Blues,不用彩虹色。彩虹色阶的亮度不是单调的,中间某些颜色看起来比两端的亮色更"重",会造成视觉误判。单色渐变的亮度是单调的,深浅直接对应数值大小,这是最稳的选择。另外记得在格子里写数字,只有颜色没有数字的矩阵图在小数位数上完全是猜的。
6.2 ROC 和 PR 曲线什么时候该用哪一个
ROC 曲线横轴是假正率、纵轴是真正率,它对类别分布不敏感——这既是优点也是缺点。优点是不管正负样本比例怎么变,同一个模型的 ROC 形状基本稳定,方便跨数据集比较;缺点是在极度不均衡的场景下,它会给出过于乐观的印象,因为假正率的分母是负样本数量,负样本极多时,即使误判数量很大,假正率的分母也把它除小了。
PR 曲线的横轴是召回率、纵轴是精确率,两者都只和正样本以及被预测为正样本的数量相关,所以在不均衡场景下更能反映实际效果。我的判断规则很简单:正样本占比低于 10% 时,主看 PR 曲线,ROC 只作为辅助参考。
两条曲线我都画在同一张图上有个好处:如果 ROC 的 AUC 很高而 PR 曲线的平均精度很低,这就是典型的不均衡场景下"看起来好其实不好"的情形,这个信号非常值得警觉。另外 PR 曲线在极端不均衡时形状会很抖,这是正常现象,因为召回率每变化一点,精确率的分母变化都很剧烈。要判断哪个阈值可靠,得看后面的阈值曲线。
6.3 阈值与指标联动曲线:真正落地的依据
模型输出的永远是概率,落地时要选一个阈值把它切成二分类。这个阈值不该凭感觉,也不该默认 0.5。我一般画一张阈值联动图:横轴是阈值从 0 到 1,纵轴同时画精确率、召回率、F1 三条线,再把业务上关注的指标(比如精确率达到某个下限、或召回率达到某个下限)标注出来。
import numpy as np from sklearn.metrics import precision_recall_curve precision, recall, thresholds = precision_recall_curve(y_true, y_prob) f1 = 2 * precision * recall / np.clip(precision + recall, 1e-9, None) fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(thresholds, precision[:-1], label="precision") ax.plot(thresholds, recall[:-1], label="recall") ax.plot(thresholds, f1[:-1], label="F1") ax.axvline(0.5, color="gray", ls="--", lw=1, label="default 0.5") ax.set_xlabel("threshold") ax.set_ylabel("score") ax.legend() plt.tight_layout() plt.show()这张图最有价值的地方是暴露"业务约束"和"最优 F1"之间的冲突。F1 最大值点往往不是业务上最合适的点——审核场景下你宁可牺牲召回换高精确,触达场景下反过来。所以我每次都把业务约束画成一条水平线,让取舍可视化,这也方便在会上和决策方讨论。
7. 回归与聚类任务的专用图:不同任务看不同的形状
前面讲的评估图主要是分类任务的,回归和聚类各有自己的一套观察方式,用错了图基本等于白画。这一章讲这两类任务里我最常用的几张图,以及它们各自能暴露什么问题。
7.1 残差图的四种形状能读出什么
回归任务里,看 R² 和 RMSE 只是看平均水平,真正定位问题要看残差图:横轴是预测值,纵轴是残差(真实减预测)。四种典型形状对应四种问题:
| 残差形状 | 含义 | 处理方向 |
|---|---|---|
| 随机散布在 0 附近 | 模型假设基本成立 | 保持,检查是否有异方差 |
| 呈喇叭形(两端散得开) | 异方差,误差随预测值放大 | 对目标取对数、加权训练 |
| 呈 U 型或倒 U 型 | 模型漏掉了非线性项 | 加多项式特征、换非线性模型 |
| 有明确的分段或条纹 | 目标值存在离散化或截断 | 检查数据采集逻辑,可能目标本身受限 |
第二条异方差在很多业务数据上都出现,尤其是金额类目标:小额订单预测得准,大额订单误差很大。取对数训练是最简单的处理方式,但要注意预测回来后要做指数还原,并且还原会引入偏差,需要用无偏修正或者直接评估原始尺度下的指标。
残差图还有一个用法是对残差画分布直方图。如果残差明显偏离正态、有长尾或者双峰,说明还有结构性信息没被模型吸收。双峰残差往往指向"数据里混了两个不同的子群体",可以考虑加一个分组特征或者直接分组建模。
7.2 预测值与真实值散点:四十五度线的价值
预测对真实的散点图,一定要加上那条 y=x 的参考线。很多人只画散点不加线,结果看不出系统性偏差。加上线之后就非常直观:点云整体偏在这条线的上方,说明模型系统性低估;偏在下方就是系统性高估;点云在某一段区间明显贴着线、另一段明显散开,说明模型在某些取值区间上失效。
样本量大的时候我会按密度着色或者用 hexbin 替代散点,因为重叠太严重。另外,横纵轴的取值范围要设成一致的,否则 45 度线看起来就不是 45 度,视觉上的偏差判断会完全错乱。
7.3 聚类:肘部法、轮廓系数和降维散点
聚类的评估本身就比监督学习模糊,因为没有标准答案。我一般会把三个视角放在一起看。
第一个是肘部法:横轴是簇的数量,纵轴是簇内平方和。随着簇数增加这个值单调下降,要找的是下降速度明显放缓的拐点。这个图的问题在于拐点经常不明显,所以只能当参考。
第二个是轮廓系数:每个点算一个值,范围 -1 到 1,越接近 1 说明该点与自己的簇越紧密、与别的簇越疏远。整体均值可以用于选簇数,但我更常用它来找异常簇——把每个点的轮廓系数画成按簇分组的条形图或小提琴图,如果某个簇整体偏低,说明这个簇的内部结构松散,可能该拆分或者该被合并。
第三个是降维之后的散点图(用主成分分析或者 t-SNE 降到二维),按簇标签着色。这张图的问题在于降维本身会扭曲距离关系,t-SNE 尤其严重——图上簇与簇之间的距离没有定量意义,只能看"是不是分得开"。所以这张图我从来不用来论证簇的质量,只用来看"有没有明显离群的一撮点"。
from sklearn.decomposition import PCA coords = PCA(n_components=2, random_state=42).fit_transform(X_scaled) fig, ax = plt.subplots(figsize=(7, 6)) sc = ax.scatter(coords[:, 0], coords[:, 1], c=labels, cmap="tab10", s=10, alpha=0.6) ax.set_xlabel("PC1") ax.set_ylabel("PC2") ax.set_title("clusters on PCA space (distance not quantitative)") plt.tight_layout() plt.show()注意做 PCA 之前一定要标准化,否则量纲大的特征会独占第一主成分,图上的结构基本只反映那一个特征。这个坑我在项目里见过不止一次,画出来的图看着簇分得很开,其实只是某个金额列把一个簇单独拉到了边上。
8. 图表工程化:脚本化、可复现与参数踩坑清单
前七章讲的都是"画什么、怎么看",这一章讲"怎么让这套东西可持续"。真实项目里图会反复重画,数据更新、模型迭代、汇报改版,每次重新手敲绘图代码是不现实的。我现在的做法是把常用图表封装成固定函数,参数化,统一样式,脚本化生成到固定目录。这套东西搭一次,后面所有项目都能省时间。
8.1 统一样式与参数模板
我习惯在项目入口设一个样式函数,所有图共用。这样最直接的好处是同一份报告里的图看起来是一套的,字号、线宽、配色都一致,读者不会因为风格跳变而分心。
import matplotlib as mpl import matplotlib.pyplot as plt def setup_style(base_size=11, dpi=120): mpl.rcParams.update({ "figure.dpi": dpi, "savefig.dpi": 200, "font.size": base_size, "axes.titlesize": base_size + 2, "axes.labelsize": base_size, "axes.grid": True, "grid.alpha": 0.25, "grid.linestyle": "--", "axes.spines.top": False, "axes.spines.right": False, "legend.frameon": False, "figure.autolayout": False, })几个参数解释一下为什么这么设。savefig.dpi我设 200,是因为屏幕预览 100 够用,但导进文档之后会明显发虚,200 是一个画质和文件体积的平衡点。axes.spines.top/right关掉,是因为这两条边框不承载任何刻度信息,去掉之后图的视觉重心更集中。legend.frameon=False同理,图例框只是装饰,去掉之后更干净。
字号我一般从 11 起步,如果图要放进投影或大屏,整体放大到 14 到 16。这里有个实用技巧:不要逐个调字号,改font.size这个基准值,其余的相对字号会自动跟着变。
8.2 中文标签、负号和分辨率这几个老问题
中文字体是最经典的坑。默认字体不支持中文,标签全都变成方块。处理方式有两种:指定一个系统里确定存在的中文字体,或者全局注册字体文件。我倾向于在项目里放一个字体文件然后显式加载,这样换机器也不会失效,比依赖系统字体名可靠。
from matplotlib import font_manager def register_font(path): font_manager.fontManager.addfont(path) name = font_manager.FontProperties(fname=path).get_name() mpl.rcParams["font.family"] = name mpl.rcParams["axes.unicode_minus"] = False # 负号正常显示 return nameaxes.unicode_minus = False这行必须加。中文字体里通常没有标准的 Unicode 减号字形,负号会显示成方块,这个问题在画有负值的坐标轴或相关系数图时几乎必然出现。我第一次遇到时排查了很久,因为报错信息里什么都没提示,图就是负号位置空了一块。
还有一个隐蔽的问题是图例截断。图例太长的时候会盖住数据或者被画布边缘裁掉。我的做法是图例超过五个条目就改成两列(ncol=2),或者干脆把图例移到图外(bbox_to_anchor)。前者适合图例较短的场景,后者适合类别特别多的场景。
8.3 保存、命名和图的目录组织
保存这块我有几个固定习惯。第一,保存和显示分开:plt.savefig()必须在plt.show()之前调用,因为某些后端在 show 之后会清空画布,之后保存得到的是空白图。这个坑每次换环境都可能复现一次。第二,用bbox_inches="tight"裁掉多余白边,不然导出的图四周会有一大圈空白,插入文档时还要裁剪。第三,文件名带上数据和参数标识,比如eda_feat_amount_log_bins50_20240315.png,图多了之后你不会记得哪张是哪张。
目录结构我一般是这样:
reports/ figures/ eda/ eval/ train/ logs/ scripts/ plot_eda.py plot_eval.py每个脚本都做成命令行可调参数的形式,数据换了直接重跑,不用改代码。这套结构看起来麻烦,但项目超过两周就会体现出价值——尤其是别人接手或者你自己隔一个月回来的时候。
8.4 绘图常见坑排查表
最后把我这些年踩过、也见过别人反复踩的问题整理成一张表,遇到图不对劲的时候可以逐条对照。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 中文显示成方块 | 未指定中文字体 | 加载字体文件并设font.family |
| 负号变方块 | 缺 Unicode 减号字形 | 设axes.unicode_minus = False |
| 导出图是空白 | show()之后才savefig() | 调整调用顺序 |
| 图例遮住数据 | 条目过多且位置默认 | 移到图外或用ncol=2 |
| 图中什么都看不到 | 存在极端值把坐标轴拉爆 | 限制xlim/ylim或先剔除极端值 |
| 散点图糊成色块 | 样本量过大且重叠 | 抽样、alpha、hexbin |
| 直方图形状与预期不符 | 分箱数不合适 | 对比auto、fd、固定值三种 |
| 热力图颜色误导 | 使用了非单调色阶 | 换单色渐变或发散型色阶 |
| 训练曲线异常抖动 | 未打乱数据或批太小 | 加shuffle=True、增大批大小 |
| 学习曲线显示数据越多越差 | 未打乱且数据有排序 | 检查数据顺序后重跑 |
我自己在实际操作中的体会是,画图这件事的时间成本几乎从来不在"写绘图代码"上,而在"看图之后判断这是不是真的"。一张图能不能信,取决于画之前数据有没有体检、参数有没有交代清楚、坐标轴有没有如实反映范围。所以每次出图之前我都会多问一句:这张图如果我错了,会错在哪里?通常这个自问就能帮你发现一两个还没处理的问题,比如坐标轴被极端值拉长、或者样本量小到不足以支撑那条看起来平滑的趋势线。