1. 拿到陌生数据先画一张离散点图,比写十行 groupby 更管用
做数据分析这行久了,你会发现一个挺反直觉的事实:真正拖慢进度的往往不是模型调参,而是对数据本身的无知。我之前接手过一个埋点日志表,字段名起得挺规整,注释也写了,看着人畜无害。结果第一版统计跑出来,某个渠道的转化率是 340%,同事差点把它当重大发现报上去。后来我用 Pandas 随手画了一张离散点图,横轴是曝光量、纵轴是转化量,图上立刻出现一条横在 y 轴高位的孤立点带——那批记录的量级根本不在正常区间里,纯粹是测试环境没洗干净的数据。
这就是数据分析中 Pandas 数据探查最朴素也最有效的一步:离散点图。它不需要你提前想好分箱,不需要你假设分布形态,把两个数值列往坐标系里一扔,重叠、聚集、离群、断层、非线性关系,全都在一张图上摊开。这篇文章我想聊的就是这件事——从一份完全陌生的 DataFrame 出发,怎么用 Pandas 配合 Matplotlib 把第一张有信息量的离散点图画出来,画图之前要做哪些准备动作,参数怎么调才不会画出一坨黑疙瘩,以及怎么靠这张图把离群点揪出来。
适合谁看?如果你刚学完 Pandas 的read_csv和groupby,能算出均值方差但不知道下一步该干嘛,这篇文章能帮你把"看数据"这件事落到实处;如果你已经做了两三年分析,日常靠 BI 拖拽出图,那这里关于采样策略、透明度计算、稳健离群判定的细节,应该也能让你在自己的脚本里改出点东西。全文的代码都是可以直接复制跑的,我尽量把每个参数为什么这么设讲透,而不是甩一段代码让你自己猜。
2. 整体思路:为什么第一步是散点而不是直方图
2.1 数据探查的四个动作,以及它们的先后顺序
我把数据探查拆成四层,顺序基本固定:看形状 → 看单列分布 → 看两列关系 → 看异常。df.info()和df.describe()解决前两层,离散点图解决后两层的一半。很多人拿到数据就急着value_counts()或者算相关系数,问题在于相关系数只描述线性关系,一个漂亮的 0.02 可能是完全无关,也可能是一条完美的抛物线——皮尔逊系数分不出来,图一眼就能看出来。
顺序不能乱的原因很实际:如果你连某列是字符串还是浮点都没确认,直接plt.scatter(df['a'], df['b']),多数字符串会画不出来,或者被当成类别索引画成一条竖线,你还会误以为是"数据分布特殊"。所以我通常强制自己按这个流程走:先shape和dtypes,再describe看量纲,再抽 3~5 个关键列两两画散点,最后才上业务逻辑。
这里有个判断标准值得记住:当你不确定两列之间是线性、分段、还是根本无关时,用离散点图;当你只想看单列的形状和分箱频次时,用直方图。散点图信息密度更高,但前提是两列都得是数值型且量级可比。量级差太远(比如一个是 0~1 的比率,一个是百万级的金额),先做归一化或者开对数轴,否则图上一个维度会被压成一条线。
2.2 离散点图在探查阶段真正能回答的五个问题
我总结了一下,一张合格的探查散点图至少能告诉你五件事,这也是我判断"这张图有没有白画"的标准:
| 要回答的问题 | 图上长什么样 | 后续动作 |
|---|---|---|
| 两列有没有关系 | 有斜向的点云带 | 才值得算相关系数 |
| 关系是不是线性 | 弯曲、分段、漏斗形 | 考虑变换或分段建模 |
| 有没有离群点 | 远离主体的孤立点/点带 | 逐条回溯原始记录 |
| 分布有没有断层 | 点云中间出现空白带 | 检查是否有两批来源不同的数据 |
| 方差是否随均值变化 | 呈喇叭口张开 | 建模时需加权或对数变换 |
第三条和第五条是纯统计量给不了的。我印象很深的一次,是排查一个物流时效表:整体平均时长很正常,但散点图在"距离 500~800 公里"区间出现一条完全脱离主云的水平点带,纵轴时效固定在 24 小时整。一查发现那批订单走的是固定班次,系统里直接写死了 24,不是真实计算出来的。这种"整齐得可疑"的模式,describe()永远看不出来。
2.3 工具选型:为什么是 Pandas + Matplotlib 而不是拖拽式 BI
有人会问,既然只是看一眼,用现成的可视化工具拖两下不就完了。我的理由有三个。第一是可复现:探查过程本身是要留痕的,脚本存下来,下次同样的表结构换个周期直接重跑,参数、采样、过滤条件全都一致,比"上次那个图我好像筛掉了前 5%"靠谱得多。第二是衔接顺畅:探查用的 DataFrame 可以直接进入后续的清洗、聚合、建模,不用在两套系统之间导来导去,中间少一次导出就少一次格式错乱的风险。第三是能处理脏数据:真实表格里数字列常常混着"1,234"、"12.5万"、"-"这类玩意,BI 工具遇到基本就是一个报错或者静默丢弃,Pandas 里一行to_numeric(errors='coerce')就能把脏值变成 NaN 交给你自己决定怎么处理。
至于 Matplotlib 和 Seaborn 怎么选,我的习惯是探查阶段用 Matplotlib 的原生scatter,因为它参数暴露得彻底,s、alpha、c、marker、norm全都能单独控制,适合反复微调;等到需要快速出一排子图给别人看的时候再上 Seaborn 的封装。Pandas 自带的df.plot.scatter()也能用,底层就是 Matplotlib,但它在颜色映射和 colorbar 上的控制偏弱,做个快速预览可以,正式分析我还是写原生。
3. 画图之前必须做的三件准备
3.1 把列类型捋直:数字被读成字符串的六种常见原因
这一步踩坑率极高。read_csv读进来的列,只要有一个非数字字符,整列就是object。我遇到过的情况包括:千分位逗号("1,234.5")、带单位后缀("36.5kg")、全角数字("123")、空字符串和空格、中文占位符("未知"、"-")、以及科学计数法和普通数字混排。这些在df.describe()里直接不显示,因为字符串列被自动排除了,你要是不看dtypes根本发现不了。
处理套路我固定成这样:
import pandas as pd import numpy as np df = pd.read_csv("raw_orders.csv", encoding="utf-8") # 第一步:先看清楚哪些列不是数值 print(df.dtypes) print(df.shape) # 第二步:对目标列做温和的清洗再转数值 def to_num(s): return pd.to_numeric( s.astype(str) .str.strip() .str.replace(",", "", regex=False) .str.replace(",", "", regex=False) .str.replace(r"[^\d\.\-]", "", regex=True) # 去掉单位和中文 .replace({"": np.nan, "-": np.nan, "nan": np.nan}), errors="coerce" ) df["exposure"] = to_num(df["exposure"]) df["convert"] = to_num(df["convert"])注意:
str.replace(r"[^\d\.\-]", "", regex=True)这一行属于"激进清洗",它会把负号以外的所有符号都删掉。如果某列天然带正负号或者有1e5这种科学计数法,先单独处理,别一把梭。我吃过一次亏:一个温度列里有-3.5和+4.2,+被删掉没问题,但另一列1e5被删成15,量级直接错了两万倍。
转完之后一定要回头再describe()一遍,看min/max和count。count明显小于总行数说明产生了大量 NaN,多半是清洗规则太狠;max大得离谱说明有量纲混用(比如有的记录是元、有的是万元)。
3.2 缺失值到底是删还是留,取决于你想看什么
探查阶段我不建议直接dropna()了事。理由很简单:缺失本身可能就是信号。如果某两个字段的缺失高度重合,说明采集环节有问题;如果缺失集中在某个时间段或者某个渠道,那就是一条业务线索。
我的做法是分两步走。画图用的那份数据先dropna(subset=[x_col, y_col]),因为 NaN 在散点图里没法定位,留着只会让你误判行数;但同时会在旁边记一笔缺失比例:
mask = df[[x_col, y_col]].notna().all(axis=1) print(f"可用样本 {mask.sum()} / {len(df)},缺失占比 {1 - mask.mean():.2%}") plot_df = df.loc[mask, [x_col, y_col]].copy()如果缺失占比超过 30%,我会停下来先查缺失原因,而不是硬画。因为这时候画出来的图只代表剩下那部分"幸运样本",很可能是有偏的——比如金额字段缺失的往往是大额订单(走线下审批,系统没回填),你看到的分布就偏小了。
3.3 采样:几百万行怎么画才不卡死也不失真
scatter的渲染开销随点数线性增长,实测下来超过 5 万点浏览器或 Notebook 就开始明显卡顿,超过 20 万点基本要等十几秒,还不算内存。但直接采样又有风险:稀疏的离群点很可能被采掉,而离群点恰恰是探查的重点。
我的策略是分层组合:主体随机采样,离群候选全量保留。判断离群候选用一个便宜的方法——按目标列的 0.5% 和 99.5% 分位数切一刀,两头的数据全部留下。
N_MAX = 5000 lo, hi = plot_df[y_col].quantile([0.005, 0.995]) tail_mask = (plot_df[y_col] < lo) | (plot_df[y_col] > hi) tails = plot_df[tail_mask] body = plot_df[~tail_mask] if len(body) > N_MAX: body = body.sample(N_MAX, random_state=42) plot_df = pd.concat([body, tails]).sort_index() print(f"采样后 {len(plot_df)} 行,其中尾部候选 {len(tails)} 行")random_state=42不是玄学,是为了让图和同事复现的时候完全一致,省掉"你这图跟我看到的不一样"这种无谓讨论。
如果数据量真的到了千万级,而且你关心的只是密度分布而不是单个点,那我建议直接换hexbin,把平面切成六边形网格做二维计数,全量数据也能秒出图,后面第 5 节会写具体用法。
4. 核心细节:让离散点图从"能看"到"看得出东西"
4.1 点重叠是最大杀手,透明度要算不要猜
新手画散点最常见的画面是一坨纯黑。原因很直接:5000 个点全是不透明的同一个颜色,重叠区域饱和成实心块,密度信息彻底丢失。解决方式就是alpha透明度,但取值不能凭感觉。
我用的经验公式是让"平均每像素覆盖点数"落在 2~3 附近,这样重叠处自然加深、单点处保持浅色:
n = len(plot_df) alpha = float(np.clip(300 / max(n, 1), 0.05, 0.8)) size = float(np.clip(6000 / max(n, 1), 6, 40))拿具体数字算一下:n = 500时alpha ≈ 0.6,size ≈ 12,点大而透,适合稀疏数据;n = 5000时alpha ≈ 0.06,size ≈ 6,点小而极淡,靠叠加显出密度。这比固定写alpha=0.3稳得多,因为你的数据量每周都在变,参数写死迟早翻车。
size的单位是"点面积的平方根",不是像素直径,所以 6 和 40 的视觉差异远比数字上看起来大。这个细节很多教程不提,导致有人把s从 10 调到 20 发现差别巨大,以为调错了。
4.2 坐标轴被极端值压扁,用分位裁剪而不是硬删
真实数据的max常常是median的几千倍。这时候全量坐标轴画出来,99% 的点会挤在左下角一个小方块里,图等于废了。常见处理有三种:直接裁掉极端值(信息丢失)、用对数轴(适合右偏但不适合含负值)、或者按分位设定显示范围而不删数据。
我一般用第三种,因为它保留全部点,只是不显示极端区域,同时会在图上标注被截断的比例:
def axis_limits(s, q=(0.005, 0.995), pad=0.06): lo, hi = s.quantile(q[0]), s.quantile(q[1]) span = hi - lo if span == 0: return lo - 1, hi + 1 return lo - span * pad, hi + span * pad xlim = axis_limits(plot_df[x_col]) ylim = axis_limits(plot_df[y_col]) ax.set_xlim(*xlim) ax.set_ylim(*ylim)pad=0.06是留 6% 的边距,避免点正好贴在框线上看不清。取 0.5% 和 99.5% 分位而不是 1% 和 99%,是因为真实离群点往往就在最外侧的 1% 里,切太狠会把你要找的东西切没。
如果数据是典型的右偏量级型(金额、访问量、时长),我倾向直接上双对数轴:
ax.set_xscale("log") ax.set_yscale("log") # 含 0 或负值时先平移 # plot_df[col] = np.log1p(plot_df[col].clip(lower=0))对数轴下,幂律关系会变成直线,比值型的差异会变成等距,判断"是不是按比例增长"特别直观。代价是坐标刻度不直观,给非技术同事看的时候要加一句说明。
4.3 用颜色和大小把第三、第四维塞进去
两个维度经常不够。比如看"曝光-转化"的时候,你会很想同时知道渠道和客单价。做法是把第三列映射到颜色,第四列映射到点大小:
sc = ax.scatter( plot_df[x_col], plot_df[y_col], s=size_scaled, c=plot_df[color_col], cmap="viridis", alpha=alpha, edgecolors="none", ) cbar = fig.colorbar(sc, ax=ax, fraction=0.035, pad=0.02) cbar.set_label(color_col)几个实战要点。类别型别用连续色图,四五个渠道用tab10这类离散色板,或者干脆每个类别单独scatter一次并给label,图例会清楚得多;连续型用viridis或cividis,别用jet,那玩意儿的亮度不是单调的,会产生虚假的边界感,看起来像有分界线其实没有。颜色映射要检查 null,c列有 NaN 的话那些点会变成透明或者异常色,提前fillna一个显著的哨兵值(比如 -1)并单独标注。
点大小s映射数值时记得做归一化,否则一个 100 万的金额会让点大到糊满屏幕:
v = plot_df[size_col].astype(float) size_scaled = 8 + 60 * (v - v.min()) / (v.max() - v.min() + 1e-9)面积感知是非线性的,人眼对面积的判断约等于对半径平方的判断,所以严格来说应该对数值开平方再映射。不过在探查阶段这个精度不重要,能分出大小档就够了。
4.4 参考线:把"我觉得这里偏高"变成可验证的判断
光看图容易凭感觉。加两条参考线,判断立刻客观:中位数线看整体偏移,四分位线看离散程度,业务阈值线看有多少点越界。
med_x, med_y = plot_df[x_col].median(), plot_df[y_col].median() ax.axvline(med_x, color="#888", lw=1, ls="--", alpha=0.8) ax.axhline(med_y, color="#888", lw=1, ls="--", alpha=0.8) # 业务阈值 ax.axhline(0.05, color="#c0392b", lw=1.2, ls=":", alpha=0.9) ax.text(xlim[0], 0.05, " 目标线 5%", va="bottom", fontsize=9, color="#c0392b")参考线我一般控制在三条以内。多了图就乱,反而干扰对点云形状的判断。
5. 完整实操:从原始表到一张能进汇报的图
5.1 端到端代码骨架
下面这段是我现在脚本里的模板,改列名就能用。它把前面讲的清洗、采样、透明度计算、分位裁剪、颜色映射全串起来了:
import numpy as np import pandas as pd import matplotlib matplotlib.use("Agg") # 无界面环境下也能出图 import matplotlib.pyplot as plt def explore_scatter( df, x_col, y_col, color_col=None, size_col=None, n_max=5000, out_path="scatter_explore.png", title=None, log_axis=False, ): d = df[[c for c in [x_col, y_col, color_col, size_col] if c]].copy() d[x_col] = pd.to_numeric(d[x_col], errors="coerce") d[y_col] = pd.to_numeric(d[y_col], errors="coerce") d = d.dropna(subset=[x_col, y_col]) usable = len(d) # 尾部保留 + 主体采样 lo, hi = d[y_col].quantile([0.005, 0.995]) tail_mask = (d[y_col] < lo) | (d[y_col] > hi) tails, body = d[tail_mask], d[~tail_mask] if len(body) > n_max: body = body.sample(n_max, random_state=42) d = pd.concat([body, tails]).sort_index() n = len(d) alpha = float(np.clip(300 / n, 0.05, 0.8)) base_s = float(np.clip(6000 / n, 6, 40)) fig, ax = plt.subplots(figsize=(9, 6.2), dpi=140) if size_col and size_col in d: v = d[size_col].astype(float) s = base_s * (0.5 + 1.5 * (v - v.min()) / (v.max() - v.min() + 1e-9)) else: s = base_s if color_col and color_col in d: sc = ax.scatter(d[x_col], d[y_col], s=s, c=d[color_col], cmap="viridis", alpha=alpha, edgecolors="none") fig.colorbar(sc, ax=ax, fraction=0.035, pad=0.02).set_label(color_col) else: ax.scatter(d[x_col], d[y_col], s=s, color="#2c7fb8", alpha=alpha, edgecolors="none") def lim(s_, q=(0.005, 0.995), pad=0.06): a, b = s_.quantile(q[0]), s_.quantile(q[1]) span = (b - a) or 1.0 return a - span * pad, b + span * pad if log_axis: ax.set_xscale("log"); ax.set_yscale("log") else: ax.set_xlim(*lim(d[x_col])); ax.set_ylim(*lim(d[y_col])) ax.axvline(d[x_col].median(), color="#888", lw=1, ls="--", alpha=0.8) ax.axhline(d[y_col].median(), color="#888", lw=1, ls="--", alpha=0.8) ax.grid(alpha=0.25, linestyle="--", linewidth=0.6) ax.set_xlabel(x_col); ax.set_ylabel(y_col) ax.set_title(title or f"{y_col} vs {x_col} (n={n}/{usable})", fontsize=12) fig.tight_layout() fig.savefig(out_path, bbox_inches="tight") plt.close(fig) return {"n_plotted": n, "n_usable": usable, "alpha": alpha, "base_size": base_s}调用就一行:explore_scatter(df, "exposure", "convert", color_col="channel", n_max=4000, out_path="v1.png")。返回的字典我会打印出来贴进分析笔记,这样过一周回头看也知道当时画的是多少点、透明度多少。
5.2 那些需要算一算的参数
几个数值我想单独解释,因为它们不是拍脑袋定的。
图幅尺寸 9×6.2 英寸、dpi 140:这是算过的。9 英寸宽在常见屏幕上约等于 900 像素,dpi 140 后输出 1260 像素宽,放进文档单栏显示不会糊,文件大小又控制在 300KB 以内。dpi 拉到 300 文件会大到 1MB 以上,探查图没必要。
透明度下限 0.05:低于这个值,浅色背景下单点基本不可见,等于白画。上限 0.8:再高一点重叠区域就饱和成实心色块了。这两个数是反复试出来的,你也可以按自己的显示器和配色微调。
采样上限 5000:实测 5000 点在普通笔记本上渲染约 0.3 秒,10 万点要 6 秒以上,而 5000 点已经能看出绝大多数结构了。真正需要看密度细节的时候换hexbin。
对比一下几种渲染方式在百万行数据上的表现,我做过一轮粗略测试(同一台机器,粗略计时,仅供量级参考):
| 方式 | 100 万行耗时 | 结构还原度 | 适用场景 |
|---|---|---|---|
| 全量 scatter + 低 alpha | 25s 以上 | 高但易糊 | 不急、要精确点位 |
| 采样 5000 + scatter | 约 0.5s | 主体结构完整,稀疏尾部靠补齐 | 日常探查首选 |
| 采样 + 尾部全保留 | 约 0.8s | 主体完整且离群不丢 | 找异常时必须用 |
| hexbin 全量 | 约 2s | 密度准确,个体点不看 | 千万级、看聚集形态 |
5.3 出图之后的命名与留痕规范
探查图很容易变成一堆plot.png、plot(1).png、plot_final.png。我的规范是{时间戳}_{x}_{y}_{筛选条件}.png,比如20240612_exposure_convert_allchannel.png。同时在脚本同目录维护一个explore_log.md,每次画图追加一行:画了哪两列、用了什么过滤、图上有什么可疑现象、下一步打算查什么。
这个习惯救过我。有一次排查一个转化率骤降的问题,翻到三个月前的笔记,上面写着"该渠道 6 月起有固定值 0.0 的记录约 200 条,疑似埋点未上报",一句话省了两小时重新排查。
6. 用离散点图定位离群点:视觉判断加统计交叉验证
6.1 先看图,再用 IQR 和 MAD 兜底
图上看到孤立的点,只是"怀疑",还不到"确认"。我一般三层验证:视觉先筛,IQR 或 Z-score 折算,最后回原表看字段。
IQR 法最通用,对非正态分布友好:
q1, q3 = d[y_col].quantile([0.25, 0.75]) iqr = q3 - q1 lo_f, hi_f = q1 - 1.5 * iqr, q3 + 1.5 * iqr outliers = d[(d[y_col] < lo_f) | (d[y_col] > hi_f)] print(f"IQR 判定离群 {len(outliers)} 条,占比 {len(outliers)/len(d):.2%}")1.5这个系数来自箱线图的经典设定,对应正态下约 0.7% 的尾部比例。想要更保守用3.0,只抓极端点。
Z-score只看偏离几个标准差,|z| > 3算离群。缺点是均值和标准差本身就被离群点污染了——一个 100 万的极大值会把标准差拉大,导致其他真正的异常反而"看起来很乖"。
MAD(中位数绝对偏差)是我的首选,因为它用中位数做中心、用绝对偏差的中位数做尺度,对污染免疫:
med = d[y_col].median() mad = (d[y_col] - med).abs().median() # 1.4826 是把 MAD 折算成正态下标准差的系数 robust_z = 0.6745 * (d[y_col] - med) / (mad or np.nan) flag = robust_z.abs() > 3.50.6745是标准正态的 75% 分位点,1.4826是 MAD 到标准差的换算常数,这两个数字记不住没关系,知道它们是"让 MAD 和标准差量纲对齐"的系数就行。
三种方法结果不一致的时候,别急着选一个。不一致本身就是信息:IQR 抓到但 MAD 没抓到的,往往是偏态分布的正常长尾;MAD 抓到但 Z-score 没抓到的,说明均值被污染了。
6.2 找到之后怎么办:删、改、留,三条路
定位到离群点后,很多人第一反应是删掉。我的原则是先归因再决定,而且一定要抽 10~20 条回到原始记录看。
| 情况 | 判断依据 | 处理 |
|---|---|---|
| 明显录入错误(单位错、小数点位错) | 值是正常值的 10 倍/100 倍 | 修正或置 NaN |
| 系统写死的占位值 | 大量记录完全相同的整数 | 标记为无效,单独分析 |
| 测试/内部账号 | 关联字段带测试标记 | 过滤 |
| 真实极端业务 | 能对应到具体大客户/特殊订单 | 保留,必要时单独建模 |
| 说不清来源 | 字段缺失严重无法回溯 | 保留但打标,做敏感性对比 |
"说不清来源就保留"这条很重要。我见过一次,某分析同学把看起来异常的 3% 记录直接删了,结果那批恰好是大额订单,删完之后客单价直接低估了 18%,结论整个反了。稳妥做法是跑两版:含离群点和剔除离群点各算一次核心指标,如果两版结论一致,说明稳健,可以放心报;如果差异很大,那离群点本身就是重点,得单独讲。
6.3 多列组合才叫真离群
单列离群好办,麻烦的是组合离群:每一列看起来都正常,但两列放一起就是不合逻辑。比如"单价 20 元、数量 9000 件"和"单价 2000 元、数量 3 件"可能都在各自正常范围内,但组合起来明显可疑。这种只能靠散点图看——它们会落在点云的空白区域,或者形成一条斜率为负的边缘带。
处理方式我用马氏距离或者简单的分位交叉过滤:
xr = d[x_col].rank(pct=True) yr = d[y_col].rank(pct=True) corners = d[((xr > 0.99) & (yr < 0.01)) | ((xr < 0.01) & (yr > 0.99))]用rank(pct=True)而不是原值,是为了避免量纲影响。抓出来的这批"高位低配"或"低位高配"记录,通常就是组合异常的候选。
7. 踩过的坑与排查速查表
7.1 常见问题对照表
| 现象 | 可能原因 | 排查动作 | 解决 |
|---|---|---|---|
| 图上什么都没有 | 目标列全是 NaN / 被过滤空了 | 打印d.shape、d[col].notna().sum() | 回查清洗规则,errors='coerce'是否吞太多 |
| 点全挤在角落 | 极端值撑大坐标轴 | describe()看 min/max | 分位裁剪或对数轴 |
| 一坨纯黑 | alpha 太高或 n 太大 | len(d) | 按公式调 alpha 和 s |
| 只有竖线或横线 | 一列是常量或几乎常量 | nunique() | 换列,或改看直方图 |
| 颜色映射不生效 | c传了字符串列 | d[color_col].dtype | 类别用字典映射成整数 |
| 中文标题变方块 | 字体未配置 | 看是否出现豆腐块 | 指定支持中文的字体族 |
| 保存的图片空白 | 先show()后savefig()导致画布重置 | 检查调用顺序 | 先 save 再 show,或直接 close |
| 时间轴标签重叠 | 刻度太密 | 看刻度数量 | 旋转标签、减少刻度数 |
| Jupyter 里内存暴涨 | 反复画全量图未释放 | 看内存曲线 | 每次plt.close(fig) |
| 图和同事看到的点不一样 | 采样未固定随机种子 | 检查random_state | 固定种子并存采样索引 |
7.2 几条不太容易搜到的实操心得
先看nunique()再看分布。一个看着像连续变量的列,nunique()只有 7,那它其实是类别,画散点会得到几条竖直的点带,很容易被误判成"数据分层"。这个检查花 1 秒,能省半小时困惑。
画之前先排序也可能有坑。有人为了让图"看起来规整"先sort_values,结果点按顺序绘制,后画的点压在先画的上面,稠密区被后面的大值点覆盖,视觉上产生虚假的密度偏移。scatter不排序反而更中性;如果确实想按某列大小决定叠放顺序,那就显式sort_values(size_col)并说明,别默默排。
颜色数量超过 8 个就考虑分面。一张图上 12 种颜色的点云,图例占半屏,谁也分不清哪个是哪个。这种情况我会按类别列groupby循环出子图,每个子图一个类别,共享坐标轴,对比更清楚。
hexbin是散点图被数据量打败时的正解。千万级数据看密度形态,用这个:
hb = ax.hexbin(d[x_col], d[y_col], gridsize=60, cmap="viridis", mincnt=1, bins="log") fig.colorbar(hb, ax=ax, label="log10(count)")gridsize=60意思是横向切 60 个六边形格子,加bins="log"是因为密度跨几个数量级时线性色阶会让稀疏区全变空。mincnt=1把空格子留白,比填 0 看着干净。
截图给人看之前,把 n 写上。标题里加(n=4980/120000)这个习惯,能挡掉一大半"你这个图怎么和我看到的不一样"的追问,因为对方立刻知道你看的是采样后的分布。
8. 我自己的几个固定动作,顺手分享
这套流程跑熟了之后,我在脚本里固化成了几个固定动作,每次拿到新表按顺序敲,不太需要动脑:dtypes和shape各打一次,两目标列describe()一次,nunique()过一遍,然后才是出图。出图之后我一定会在图上加中位数参考线,因为纯看形状容易高估或低估整体水平。
还有一个习惯是多画一张——图上觉得没问题,我会再换一对列画第二张,尤其是把"金额"和"数量"这类互为乘积关系的两列放一起。真实数据里这俩经常一个涨一个跌,形成一条漂亮的双曲线,那是天然的校验关系:乘积应该约等于总额,偏离这条曲线的行,八成有字段错填或者折扣没记录。这个技巧我在三四个不同业务的表上都用过,每次都能翻出点东西,成本只是多写一行scatter。