做数据分析的人大概都有过这样的时刻:数据拿到手,行数、列名、类型都看过了,缺失值也统计了一遍,心里觉得“差不多了,可以开始建模了”,结果跑出来的模型指标忽高忽低,回头一查,某几行数值离谱得离谱。这类问题,十有八九在数据探查阶段就该被发现,只是当时没人把两列数据摆在一张散点图上看一眼。这篇内容聊的就是Pandas 数据探查里最不起眼、却最能救命的一步——离散点图(也就是我们常说的散点图分布分析),用它去找离群点和异常值,把数据的“形状”先摸清楚。它不需要复杂算法,几行代码就能出图,适合刚接触数据分析的新手,也适合做了几年却常常跳过可视化直接上手建模的老手。下面我会把散点图放在整个探查链路里讲清楚位置,再拆解画法、判别方法、踩坑记录和脚本骨架,尽量做到看完能直接抄进自己的 notebook 里跑。
1. 别急着算指标,先看清数据长什么样
1.1 数据探查这一步到底在探什么
很多人理解的“探查”就是df.info()加df.describe(),跑完打印一堆统计量,看着挺专业,实际上信息密度很低。平均值、标准差这些东西是压缩过的信息,它把几千行的分布压缩成一两个数字,分布的形状、双变量之间的关系、极端值的具体位置,全被压没了。真正的探查至少要做四件事:一是结构和类型,确认每列的 dtype 是不是跟业务含义对得上,日期列是不是被读成了字符串,金额列是不是被读成了 object;二是缺失情况,缺失比例、缺失是否成片出现、缺失本身有没有业务含义;三是单变量分布,值域、偏态、峰值、有没有明显不可能出现的取值;四是变量之间的关系,两两之间是线性、是曲线、还是压根没关系。
前两件事用info、isnull、value_counts就能搞定,第三件事用直方图和箱线图解决,第四件事就得靠离散点图了。顺序上我不建议跳步:先看结构和缺失,再看单变量,最后看双变量关系。原因很实在——如果某列里有 30% 的缺失,你画出来的散点图会少掉三分之一的数据点,图上看不见的那些点可能恰恰是最异常的,先知道缺失情况,看图时才不会误判。反过来,如果先看关系再看缺失,很容易把“因为缺失导致的关系不明显”错当成“两列无关”。
提醒:
describe()的统计量只对数值列有效,object 列和 category 列要单独用value_counts()看,别指望一行 describe 覆盖所有列。
1.2 离散点图在整条链路里的位置
散点图的本质是把两列数值分别映射到横纵坐标,每一个数据点是一个样本。它一次能同时呈现三件事:相关性的方向和强弱、分布的聚集与稀疏程度、以及离群点的位置和数量。这三点在纯数字表里是看不见的,必须靠图形。举个具体的例子,一批订单数据里,“订单金额”和“商品件数”理论上应该高度正相关,单看两列的均值都在正常范围,但散点图一画,你会发现右上角有几个点,件数只有个位数,金额却是六位数——这类数据在统计量里不明显,在图上非常扎眼。
我习惯把离散点图放在探查的第二个环节,也就是在单变量分布之后、特征工程之前。理由很直接:单变量图告诉你每一列自己有没有毛病,双变量图告诉你列与列之间有没有矛盾。数据的可信度,往往就藏在“列与列对不上”的地方。比如“注册日期”晚于“首次下单日期”,“累计消费金额”除以“订单数”得到的客单价低得不合理,这些矛盾单看一列都正常,两列一对照就露馅了。散点图就是做这个对照最廉价的工具,没有之一。
1.3 一个跳过这步的翻车现场
说个我听来的例子,某团队做一个用户流失预测的小项目,数据量几万行,特征做了标准化,模型换了两三种,AUC 一直卡在 0.6 出头,怎么调都上不去。折腾了一个多星期,后来有人顺手画了一张“最近一次活跃距今天数”和“累计使用时长”的散点图,问题一眼看出来了:有几个样本的“距今天数”是负数。负数的天数在业务上完全说不通,说明数据导出时的时区或者系统时间出了问题。这几个点数量不多,但在标准化之后会被放大成极端值,把整个特征分布的尺度拉歪,模型自然学不好。
删掉或者修正这几个点之后,同样的模型、同样的参数,指标直接上了一个台阶。这个例子说明的事很朴素:异常值不会自己举手报告,它安静地躺在表里,直到你在图上看见它。而且这类问题越早发现越便宜,等做完特征工程、跑完交叉验证再回头,时间和算力都浪费了。所以我的个人原则是:任何一份新数据,在写第一行特征代码之前,至少要扫一遍双变量散点图,重点列全覆盖,非重点列抽样看。这个习惯几乎不需要额外成本,十几分钟的事。
1.4 谁需要把这一步做扎实
如果你是学生或者刚转行,正在做课程项目、练手数据集,这一步能让你养成正确的顺序感,别一上手就fit,先图后模是好习惯。如果你是从业者,日常处理业务数据,这一步能帮你在评审会上少被问倒,别人问“这列数据你确认过吗”,你能拿图说话。如果你是带人的人,把这份检查清单固化成模板,团队里每个人拿到新数据都跑一遍,能省掉大量“数据有问题”的来回扯皮。
反过来说,什么情况下这一步可以简化?数据量极小(几百行以内)、字段极少(三五个)、来源完全可信(自己刚生成的模拟数据),那看一眼 describe 也就够了。但只要数据来自外部、经过多人转手、跨系统拼接,就别省这一步。数据的可信度和它的流转次数成反比,这个经验我至今没见它失效过。
2. 环境搭稳,样例数据先跑通
2.1 依赖选择和版本上的小坑
做这件事只需要三样东西:Pandas做数据操作,Matplotlib出底层图,Seaborn可选,用来做分组着色和密度增强。装的时候建议把 NumPy 一起装上,造样例数据要用。版本上我建议 Pandas 用 1.5 以上或 2.x,Matplotlib 3.5 以上,太老的版本在plot.scatter的参数支持上有差异,比如s、c、alpha的接受类型在旧版本里比较挑剔。如果你在 Jupyter 里跑,记得开头加一行%matplotlib inline,否则有些环境里图不会直接显示,只打印一个对象地址,新手容易被这个吓到,以为是绘图失败。
另一个常被忽略的点是后端。在无图形界面的服务器上跑脚本,默认后端可能会报错或者静默不保存图片。稳妥做法是在导入 pyplot 之前设置matplotlib.use('Agg'),这样不弹窗,但savefig能正常出文件。我在本地写脚本时通常两套环境分开:notebook 里用交互后端边看边调,正式脚本用 Agg 后端批量输出,互不干扰。
# 依赖安装,建议放进虚拟环境 pip install pandas matplotlib seaborn numpyimport pandas as pd import numpy as np import matplotlib matplotlib.use('Agg') # 无界面环境必备,本地 notebook 可去掉 import matplotlib.pyplot as plt import seaborn as sns plt.rcParams['font.sans-serif'] = ['SimHei'] # 中文标题不乱码 plt.rcParams['axes.unicode_minus'] = False # 负号正常显示 print(pd.__version__, matplotlib.__version__)2.2 造一份可控的样例数据
练习阶段最忌讳直接拿一坨真实数据上来就调,因为你不确定图上的异常到底是真的异常还是自己代码写错了。我的做法是先造一份已知答案的数据:主体部分服从一个明确的关系,然后手工塞进去几个“我确定它是离群点”的记录,看自己的代码能不能把它们揪出来。这样图一出来,对不对一眼就知道。代码不用复杂,np.random.seed固定随机种子保证可复现,normal造主体,concat拼上手工构造的异常行。
np.random.seed(42) n = 800 x = np.random.normal(50, 12, n) # 模拟某指标 A y = 2.1 * x + np.random.normal(0, 18, n) # 与 A 近似线性相关 base = pd.DataFrame({'metric_a': x, 'metric_b': y}) # 手工注入 6 条已知离群点 outliers = pd.DataFrame({ 'metric_a': [95, 98, 12, 10, 60, 62], 'metric_b': [20, 15, 210, 230, 260, 245] }) df = pd.concat([base, outliers], ignore_index=True) print(df.shape) # (806, 2) print(df.describe().round(2))跑完你会看到metric_a的最小值 10、最大值 98,metric_b的最大值 260,这些都在 describe 里露头了,但你还是不知道它们是不是同一批样本。这就是统计量的局限,也是散点图存在的理由。把注入的那 6 行在图上找出来,是我每次教新人时的第一个小练习,通常五分钟就能建立直觉。
造数据时有一点要注意:主体数据的量级要和注入的异常点拉开差距,否则异常点混在正常分布里不明显。上例中主体metric_b大致在 105 附近波动,注入点要么只有十几,要么两百多,视觉上会明显跳出来。如果两者量级接近,图上就是一团糊,你反而会怀疑自己的方法有问题。
2.3 读入真实数据前的三项检查
换成真实数据,正式的读取之前有三项检查别省。第一,编码。中文环境下的 csv 经常是 GBK 或 GB18030,直接pd.read_csv可能抛UnicodeDecodeError,也可能不报错但读出乱码。我的习惯是先试utf-8,失败再试gbk,实在不行用chardet猜一下。第二,分隔符和表头。有些导出文件第一行是标题说明,不是列名,需要skiprows;有些用分号或制表符分隔,需要显式sep。第三,数值列被读成字符串。这在国内数据里极其常见,比如"1,234.5"带千分位逗号,或者带有单位后缀"35kg",Pandas 一律当字符串处理。
# 读取与类型矫正 df_raw = pd.read_csv('data.csv', encoding='utf-8') df_raw.columns = [c.strip() for c in df_raw.columns] # 去掉列名首尾空格 # 找出本该是数值、实际是 object 的列 obj_cols = df_raw.select_dtypes(include='object').columns.tolist() for col in obj_cols: converted = pd.to_numeric( df_raw[col].astype(str).str.replace(',', '', regex=False), errors='coerce' ) bad = df_raw[col].notna().sum() - converted.notna().sum() print(f'{col}: 转换失败 {bad} 条')注意:
errors='coerce'会把转不动的值变成 NaN,转换之后一定要统计有多少条变成了 NaN。如果不统计,直接把这一列拿去算均值,结果会静默偏小,比报错更危险。
这一步做完,你才有资格谈“图上的点是什么意思”。我见过不止一次,新人把字符串列直接丢给散点图,Matplotlib 报了个晦涩的类型错误,然后花了半小时去搜“scatter 报错”,其实根子在数据类型上。先修类型,再画图,顺序别倒。
3. 离散点图的几种画法,各有各的场合
3.1 一行代码的快速版
最省事的画法就是 Pandas 自带的.plot.scatter,它本质是对 Matplotlib 的封装,好处是不用管 fig、ax 的创建流程,直接给列名就行。
ax = df.plot.scatter( x='metric_a', y='metric_b', s=18, c='steelblue', alpha=0.6, figsize=(8, 5), grid=True, title='metric_a 与 metric_b 的散点分布' ) ax.set_xlabel('指标 A') ax.set_ylabel('指标 B')这段在看一眼的场景下完全够用,尤其是一次要扫十几对变量的时候。但它有几个短板:想标出具体的异常点、想按第三个变量着色、想加参考线,都得绕回 Matplotlib 的 ax 对象去改,不如直接上底层。我的实际用法是:第一遍快速扫用.plot.scatter,确定要深挖某两张图的时候切到 Matplotlib 手工控制。这个分工能省不少时间,因为不是每一对变量都值得精雕细琢。
参数上有几个值得说。s是点面积,默认值偏大,几百个点以上建议降到 10 到 20;alpha是透明度,0.3 到 0.6 之间最好用,能通过颜色叠加看出密度;c给单色字符串就行,如果要按列着色就传 Series 并配上cmap。figsize别用默认值,默认的图太小,几千个点挤在一起什么都看不出来,8×5 是我的起步尺寸,点多了会加到 12×7。
3.2 手工控制的完整版
需要标注、需要参考线、需要双图对照的时候,老老实实写 Matplotlib。
fig, ax = plt.subplots(figsize=(9, 6)) ax.scatter(df['metric_a'], df['metric_b'], s=16, alpha=0.55, edgecolors='none', label='样本点') # 用 IQR 划出两条参考带(后面第 4 节详述) q1, q3 = df['metric_b'].quantile([0.25, 0.75]) iqr = q3 - q1 lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr ax.axhline(lo, ls='--', lw=1, color='gray', label='下界') ax.axhline(hi, ls='--', lw=1, color='gray', label='上界') ax.set_xlabel('指标 A'); ax.set_ylabel('指标 B') ax.set_title('metric_a 与 metric_b 分布及异常带') ax.legend(loc='upper left', frameon=False) ax.grid(alpha=0.25) plt.tight_layout() plt.savefig('scatter_basic.png', dpi=160, bbox_inches='tight')提示:
tight_layout()和bbox_inches='tight'双保险,能避免保存出来的图被切掉坐标轴标签。这两个都加,成本几乎为零。
手工版最大的价值是能叠加信息。图上除了点,还能画分位线、画拟合趋势线、把特定的点圈出来。信息叠得越多,读图时下的判断就越有据可依。但也要克制,一张图上叠超过四层信息就开始乱了,我的一般上限是:点 + 两条参考线 + 少量标注 + 图例,再多就拆图。
3.3 大数据量下的密度处理
数据上万行以后,散点图会退化成一块色斑,这时候纯粹的点图失去意义。三个处理办法,按使用频率排序。
第一个是降透明度和缩小点面积,s=4、alpha=0.15,用颜色深浅表达密度,简单粗暴但有效。第二个是抽样,从整体里随机抽几千条画图,注意固定random_state,保证每次抽出来的样本一样,不然你前后两次看图得出不同结论,会怀疑人生。第三个是hexbin 六边形分箱图或者二维直方图,把平面切成网格,用颜色表示每格里的点数,密度看得最清楚,代价是看不出单个离群点的精确位置。
# 方案一:抽样 + 低透明度 sample = df.sample(n=min(3000, len(df)), random_state=42) ax1 = sample.plot.scatter(x='metric_a', y='metric_b', s=8, alpha=0.25, figsize=(8, 5), title='抽样 3000 点,alpha=0.25') # 方案二:六边形分箱,看整体密度 fig, ax2 = plt.subplots(figsize=(8, 6)) hb = ax2.hexbin(df['metric_a'], df['metric_b'], gridsize=40, cmap='YlGnBu', mincnt=1) fig.colorbar(hb, ax=ax2, label='网格内样本数') ax2.set_xlabel('指标 A'); ax2.set_ylabel('指标 B')实操建议:先用抽样点图找离群点,再用 hexbin 看主分布,两个视角互补。离群点在 hexbin 上通常表现为孤立的、颜色极浅的小格子,不难认,但精确位置不如点图直观。另外抽样的时候别只抽一次,我一般会抽两次对比,如果两次抽出来的图形状差很多,说明数据分布本身不均匀,简单随机抽样有偏,这时候要么分层抽样,要么干脆用全量配透明度。
3.4 第三个维度的引入方式
两列散点图只能看二维关系,但数据的异常往往跟第三个变量有关。比如某笔订单的金额和件数都在合理范围,但它发生在一个从未出现过的渠道上。这时候就可以用颜色把第三个变量编码进去。
# 按类别着色 categories = np.where(df['metric_a'] > 60, '高值组', '常规组') colors = {'高值组': '#d9534f', '常规组': '#4a90d9'} fig, ax = plt.subplots(figsize=(9, 6)) for name in ['常规组', '高值组']: sub = df[categories == name] ax.scatter(sub['metric_a'], sub['metric_b'], s=16, alpha=0.6, label=name, c=colors[name], edgecolors='none') ax.legend(frameon=False) ax.set_title('按指标 A 高低分组着色') plt.tight_layout()用颜色编码类别时有个硬性要求:类别数控制在 5 到 8 个以内,超过之后颜色就分不清了,人眼对色相的区分能力有限。如果类别很多,要么合并小类,要么改成分面小图,用subplots按类别各画一张。千万别用彩虹色系编码连续值,那种图看着花哨,实际上没人能准确读出色阶对应的数值,换个顺序感知的色板(比如黄到蓝的渐变)会好很多。
4. 从“看着怪”到“有依据”:量化判别配合图
4.1 为什么光看图不够
图能告诉你“这里有几个点不对劲”,但说不出“为什么不对劲”,也说不清“到底有多少个”。更麻烦的是,判断会因人而异:同一个点,我今天看着像异常,明天看着又觉得能接受。要下结论,得有量化标准配合。我的做法是先图后算,图算互验——图用来发现疑点,计算用来确认边界,最后把算出来的边界画回图上复核,三步走完才敢动数据。
这里要强调一个观念:离群点不等于错误数据。离群可能来自真实的极端业务场景(大客户、促销日、突发流量),也可能是录入错误、系统故障、单位换算错误。前一种要保留,甚至要单独分析;后一种必须处理。散点图的价值在于把候选集找出来,但要不要删、怎么删,得靠业务判断。我踩过的最大的坑,就是早期拿到数据就按 IQR 一刀切掉,结果把一个真实大客户的记录删了,后来对账时才发现金额对不上,那种尴尬很难解释。
4.2 IQR 四分位距法:最稳妥的默认选择
IQR 的思路很朴素:把数据按大小排好,取 25% 分位和 75% 分位,中间这段就是主体分布,然后向外扩 1.5 倍这个宽度,超出范围的算可疑。它的好处是不假设数据服从正态分布,对偏态数据比标准差法稳得多,而且计算简单、边界直观,画成横线往图上一放,谁都能看懂。
| 参数 | 含义 | 常用取值 |
|---|---|---|
| Q1 | 25% 分位数 | 由数据决定 |
| Q3 | 75% 分位数 | 由数据决定 |
| IQR | Q3 − Q1 | 由数据决定 |
| k | 边界系数 | 1.5(常规)/ 3.0(极端离群) |
def iqr_bounds(series, k=1.5): q1, q3 = series.quantile([0.25, 0.75]) iqr = q3 - q1 return q1 - k * iqr, q3 + k * iqr lo, hi = iqr_bounds(df['metric_b'], k=1.5) mask = (df['metric_b'] < lo) | (df['metric_b'] > hi) print(f'边界: [{lo:.2f}, {hi:.2f}],命中 {mask.sum()} 条') print(df.loc[mask, ['metric_a', 'metric_b']].head(10))k选 1.5 还是 3.0,取决于你的容忍度。1.5 会揪出一批“轻度偏离”的点,适合做数据清洗前的普查;3.0 只抓极端值,适合你不想动太多数据的时候。我个人的用法是两个都算一遍,差集单独看一眼:3.0 抓出来的基本可以确认有问题,1.5 到 3.0 之间的这批要人工过一遍,看是不是业务上的真实极端值。这个过程费点时间,但比事后返工划算得多。
注意:IQR 是逐列独立算的,它对“单列数值离谱”很敏感,但对“单列都正常、组合起来矛盾”的情况完全无能为力。后者只能靠散点图看点位关系,或者构造比值类特征再算。这一点必须心里有数。
4.3 标准分与稳健标准分
标准分(Z-score)的思路是看每个点离均值有几个标准差,通常以 |z| > 3 作为阈值。它的前提是数据近似正态,用在偏态数据上会出问题:右偏的金额数据,均值和标准差都被右侧长尾拉大了,导致真正的异常点 z 值反而不大,漏检。所以这个方法我只在确认过分布形状、或者做过对数变换之后用。
from numpy import median, absolute z = (df['metric_b'] - df['metric_b'].mean()) / df['metric_b'].std(ddof=0) print('Z-score 命中:', (z.abs() > 3).sum()) # 稳健版:用中位数和 MAD 代替均值和标准差 med = df['metric_b'].median() mad = median(absolute(df['metric_b'] - med)) if mad > 0: rz = 0.6745 * (df['metric_b'] - med) / mad print('修正 Z 命中:', (rz.abs() > 3.5).sum())稳健标准分用中位数和绝对中位差,这两个统计量对极端值不敏感,所以异常点自己不会把“标尺”撑大,检出率更接近预期。代价是它对分布中心的偏移不敏感,如果异常点占了相当比例(比如超过 10%),中位数本身就被污染了,这招也不灵。所以无论用哪种方法,最后都要回到图上确认:把判定结果标在散点图上看一眼,比看十行数字都管用。
4.4 把判定结果标回图上
这是整个流程里我最看重的一步。算完边界、拿到索引,用不同的颜色和大小把候选点圈出来,再存一张图。这张图既能自己复核,也能直接拿去跟业务方沟通——“你看这几个点,是录入错误还是真实业务”,比口头描述高效得多。
mask_out = (df['metric_b'] < lo) | (df['metric_b'] > hi) fig, ax = plt.subplots(figsize=(10, 6)) ax.scatter(df.loc[~mask_out, 'metric_a'], df.loc[~mask_out, 'metric_b'], s=14, alpha=0.5, c='#9bb7d4', edgecolors='none', label='常规样本') ax.scatter(df.loc[mask_out, 'metric_a'], df.loc[mask_out, 'metric_b'], s=90, alpha=0.9, c='none', edgecolors='#c0392b', linewidths=1.6, label='IQR 候选离群点') ax.axhline(lo, ls=':', lw=1.2, color='#c0392b') ax.axhline(hi, ls=':', lw=1.2, color='#c0392b') ax.set_xlabel('指标 A'); ax.set_ylabel('指标 B') ax.set_title('离群点标注结果(k=1.5)') ax.legend(frameon=False, loc='upper left') ax.grid(alpha=0.2) plt.tight_layout() plt.savefig('scatter_outlier.png', dpi=160, bbox_inches='tight')图出来之后,重点看两件事。第一,被圈出来的点里,有没有明显属于主体分布内部的——如果有,说明阈值设得偏紧,或者这一列本身就偏态严重,该换方法。第二,主体分布边缘有没有明显该圈却没圈的——这通常是分布不规则导致的线性阈值失效,比如呈弧形分布的数据,用水平线切显然不合适。遇到这种情况,别硬套公式,把两个变量的比值或者差值做成新的一列再算 IQR,往往比在原始列上切更合理。
5. 出图过程中的问题排查实录
5.1 图不显示、中文变方块、负号错乱
这三件事几乎每个人都会遇到,而且报错信息不明确,容易卡住。图不显示,先看是不是少了%matplotlib inline,再看是不是在无界面环境跑了交互后端,最后检查plt.show()有没有被注释掉。中文变方块,是因为默认字体不含中文,设置font.sans-serif即可,注意字体名要跟系统里实际装的对上,Windows 常用 SimHei,Linux 服务器上未必有,得换成开源的思源黑体或者文泉驿。负号错乱表现为负号变成小方块,加一行axes.unicode_minus = False就好,原理是关掉 Unicode 负号,改用 ASCII 减号。
提醒:服务器上跑脚本时中文字体经常缺失,最省事的做法是图中文字全用英文,标题用英文短语,避免折腾字体。如果必须出中文图,把字体文件一起部署,别依赖系统自带。
5.2 点全糊成一团,看不出密度
数据量一大就容易糊。排查顺序:先看s是不是太大了,几百个点用 20、几千个点用 8、上万点用 3 到 4;再看是不是没设alpha,不透明度的点叠在一起只会显示最上层那个颜色;最后看figsize是不是太小,8×5 塞一万个点,神仙也看不出结构。这三项调完还不行,就是数据本身密度太高,上抽样或者 hexbin。
还有一种情况是两个变量的量级差距太大,比如横轴是 0 到 100 的评分,纵轴是几十万的金额,图上所有点都会贴在底部,看着像一条线。这时候最好的办法不是硬画,而是对广义跨度大的变量取对数,或者干脆拆成两张单变量图分别看。对数变换后记得在坐标轴标题里注明,不然过几个月自己都忘了自己做过变换。
5.3 时间轴刻度挤成黑边
横轴放日期的时候,刻度和标签会重叠成一坨黑。原因通常是把日期当成字符串直接传进去了,Pandas 没识别成时间类型。正确做法是先用pd.to_datetime转成 datetime64,Matplotlib 会自动用日期定位器。如果转完还是挤,两个办法:plt.xticks(rotation=45)旋转标签,或者用mdates.AutoDateLocator控制刻度间隔,让它在合理的粒度上自动选点。
df['order_date'] = pd.to_datetime(df['order_date'], errors='coerce') print('转换失败条数:', df['order_date'].isna().sum()) fig, ax = plt.subplots(figsize=(11, 5)) ax.scatter(df['order_date'], df['metric_b'], s=10, alpha=0.5) ax.xaxis.set_major_locator(mdates.AutoDateLocator(maxticks=10)) ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) plt.setp(ax.get_xticklabels(), rotation=30, ha='right')注意:
to_datetime加上errors='coerce'之后,一定要数一下有多少条变成了 NaT。国内数据里日期格式混乱非常常见,“2023/1/5”“2023-01-05”“20230105”混在同一列里,转换失败的记录如果直接丢掉,样本量会悄悄缩水。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 图完全不显示 | 缺内联魔法命令 / 后端不匹配 | 加%matplotlib inline,脚本用 Agg 后端 |
| 中文显示为方块 | 字体不含中文 | 设置font.sans-serif为已装中文字体 |
| 负号显示异常 | Unicode 负号 | axes.unicode_minus = False |
| 点糊成一团 | 点太大 / 无透明度 / 画布太小 | 降s、加alpha、放大figsize |
| 全图只有一个点 | 数据类型是字符串 | 先用to_numeric转换 |
| 日期刻度重叠 | 未转 datetime | pd.to_datetime+ 自动定位器 |
| 保存图片空白 | 保存前没画完 / 后端问题 | 先tight_layout再savefig |
| 保存图片被裁切 | 标签超出画布 | bbox_inches='tight' |
| 抽样后结论反复 | 未固定随机种子 | random_state固定整数 |
| 异常点数量异常多 | 数据本身偏态严重 | 换稳健标准分或先做对数变换 |
6. 把这套动作固化成习惯
6.1 一个可以反复用的脚本骨架
单次分析靠手敲没问题,长期做数据的人应该把它固化成函数,每次拿到新数据换个列名就能跑。我自己的模板长这样,结构很土但好用:先做类型矫正,再对指定列两两画散点,同时输出 IQR 候选列表,最后统一保存。
def quick_scan(df, x, y, save_dir='./scan', k=1.5, sample_n=5000): import os os.makedirs(save_dir, exist_ok=True) d = df[[x, y]].apply(pd.to_numeric, errors='coerce').dropna() lo, hi = iqr_bounds(d[y], k) flag = (d[y] < lo) | (d[y] > hi) if len(d) > sample_n: d_plot = d.sample(sample_n, random_state=42) else: d_plot = d fig, ax = plt.subplots(figsize=(9, 5.5)) ax.scatter(d_plot[x], d_plot[y], s=10, alpha=0.45, edgecolors='none') ax.axhline(lo, ls=':', color='#c0392b', lw=1) ax.axhline(hi, ls=':', color='#c0392b', lw=1) ax.set_title(f'{x} vs {y}') ax.grid(alpha=0.2) plt.tight_layout() fig.savefig(f'{save_dir}/{x}__{y}.png', dpi=150, bbox_inches='tight') plt.close(fig) return {'x': x, 'y': y, 'lo': round(lo, 3), 'hi': round(hi, 3), 'hit': int(flag.sum()), 'total': len(d), 'rate': round(flag.mean() * 100, 2)}这套骨架有三个设计考虑,值得说一下。第一,先 dropna 再统计,避免 NaN 干扰分位数计算,同时也顺便知道了这一对变量有多少条是完整的。第二,超过阈值就抽样画图,但统计用全量,绘图是给人看的,统计是给判断用的,两者可以分开。第三,每张图画完立刻close,批量跑几十对变量的时候不关图会吃掉大量内存,尤其在服务器上,跑到一半内存爆掉很常见。
输出的统计字典可以攒成一个列表,最后pd.DataFrame(rows).to_csv存成一张总表,按命中率排序。命中率特别高的那几列(比如超过 10%)基本可以判定是分布问题而不是异常点问题,要单独处理。
6.2 可复现性和留痕
做探查最怕的是“我上周好像看过,但结论忘了”。我的习惯是三个东西必须落盘:图的 PNG 文件、统计结果的 CSV、以及一份简短的文字结论(哪几列有问题、怎么判断的、准备怎么处理)。文字结论不用长,三五句话就行,但一定要写,因为一周之后你只会记得“好像有问题”,具体是什么问题基本想不起来。
随机种子统一用一个固定值,比如全部写random_state=42,别有的地方写 0 有的地方写 42,否则复现的时候对不上。列名和文件名保持一致,x__y.png这种命名方式看着丑,但用ls一扫就知道哪对变量画过、哪对没画,比任何花哨的命名都实用。还有一点,图一定要带上标题和坐标轴标签,哪怕是临时图。我自己吃过亏,做完一堆图直接扔进汇报材料,被问“这张是哪个变量”的时候翻代码翻了半天。
6.3 什么时候该停,什么时候该深挖
最后一个问题是节奏。探查是无穷无尽的,你永远能想出新的切分角度,但项目有 deadline。我的经验是设三道闸:第一道,核心业务列全部过一遍双变量散点,这一步别省;第二道,命中率超过 5% 的列单独深挖,看是分布偏态还是真的有问题;第三道,涉及金额、身份、时间的关键列做交叉校验,比如金额和件数是否匹配、日期先后是否合理。
三道闸过完,即使还有没看到的角度,也基本不会出大问题。反过来说,如果时间特别紧,至少把第三道闸走完,因为那几类列出问题的影响最大。我在实际项目里发现,绝大多数数据事故都发生在少数几个关键列上,把这几列吃透,收益远大于漫无目的地扫全表。
另外一个个人体会是:别在探查阶段做决定性的数据修改。发现异常点,先标记、先记录,把清洗逻辑单独放在一个脚本里,原始数据保持不动。我早期的坏习惯是在探查的 notebook 里边看边改,改着改着原始数据变成了中间状态,等到要复查某条记录时,已经找不回原样了。现在我的 notebook 里只干两件事:看图、记结论。清洗逻辑一律另开文件,写清楚每一步的输入输出,这样出了问题能一步步往回倒。
散点图这件事,说穿了就是几行代码,没有算法门槛,也不需要什么高级工具。但它解决的恰恰是最容易被跳过的问题:数据到底可不可信。图上多看一眼,后面能省下的返工时间,远比画图花掉的十几分钟多。