只讲可视化不讲预处理,画出来的图就是空中楼阁。这是我带过多个数据项目之后最深的体会。很多初学者拿到数据集第一件事就是敲plt.plot(),结果画出来的图要么全是缺失值造成的断崖,要么被极端异常值拉得看不出分布形态,要么两个类别数量悬殊导致图表完全失真。这一讲我就围绕"数据预处理与可视化工作流"这个主题,完整走一遍从原始表格到成品图表的全过程,把每个环节为什么要做、怎么做、做完了怎么验证一次讲清。
1. 为什么预处理是可视化不可省略的前置环节
1.1 一张未经预处理的图能错到什么程度
先看一个常见的反面教材:某销售数据集包含12个月的订单金额,其中2月份因为系统升级导致大量记录缺失,6月份出现一笔金额为999999的异常测试订单。如果直接把数据丢给matplotlib画折线图,2月份会直接断线,6月份会被异常值顶出一个冲天尖峰,整张图的纵轴范围被拉伸到正常月份只能贴在地面上。这张图如果拿去给业务部门看,结论会是"2月业绩崩塌、6月爆发式增长",而真实情况完全不是这样。
更隐蔽的问题发生在分布图里。seaborn的histplot在数据包含极端离群值时,会自动把坐标轴范围扩展到异常值附近,导致大部分数据被压缩在几个孤零零的bin里,直方图看起来像一根倒立的钉子,根本看不出真实分布。箱线图稍微好一些,但plt.boxplot()默认的whisker范围是1.5倍IQR,异常值会被单独标成圆圈,如果不理解这个机制,很容易把正常数据误判成异常。
有人说"我先用df.describe()看一眼再画图",这确实是好习惯,但describe()只能告诉你数值列的均值、标准差、分位数,它不会告诉你哪一列有300个NaN,不会告诉你省份列里"广东省"和"广东"其实是同一个地方,更不会告诉你有65行的时间戳格式是2024/1/5、其余是2024-01-05。这些信息只能靠系统的数据审查才能暴露,而审查本身就是预处理的一部分。
1.2 预处理与可视化在项目中的实际分工
在真实项目中,预处理和可视化不是两个独立的阶段,而是一个循环迭代的过程。我通常的习惯是:先做一遍基础质量审查(缺失值、重复值、数据类型、唯一值计数),画一版"摸底图"——注意这一版图不用于汇报,纯粹为了自己看分布;根据摸底图发现的问题(比如长尾分布、离群点),再针对性地进行清洗和转换;清洗之后再画正式图,并且用多个视角交叉验证,比如同一份数据分别用直方图、箱线图、KDE曲线对比,确认预处理没有引入新的错误。
这个循环听起来简单,但实际操作中最容易犯的错是"清洗过度"。我曾经见过一个项目,预处理阶段把所有超出3倍标准差的记录全部删除,理由是"这些是异常值",结果删完之后模型效果大打折扣。后来复盘才发现,那些数据对应的是双十一期间的真实高并发订单,根本不是异常,而是业务特征。所以预处理和可视化之间必须保留"验证环节"——用可视化去检查预处理的效果,而不是清洗完就直接进模型或直接出图。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与示例数据集构建
2.1 工具链选型与安装注意事项
这一讲涉及的工具链是pandas、numpy、matplotlib、seaborn,外加一个用于交互式探索的missingno。安装命令非常简单:
bash复制pip install pandas numpy matplotlib seaborn missingno
我强烈建议在虚拟环境里装,别直接装到系统Python里。virtualenv或者conda都行,原因是不同项目的依赖版本经常冲突——比如你同时维护一个用pandas 1.5的老项目和用pandas 2.2的新项目,升级系统级pandas会让老项目直接崩掉。踩过这个坑之后,我所有新项目第一件事就是建独立虚拟环境。
如果你已经装了但缺某一个包,运行时会看到类似ModuleNotFoundError: No module named 'seaborn'的错误,这时只需要pip install seaborn即可,不需要重新装一遍全部依赖。国产镜像源比默认PyPI快得多,实测下来清华源和阿里源都稳定:
bash复制pip install pandas numpy matplotlib seaborn missingno -i https://pypi.tuna.tsinghua.edu.cn/simple
版本说明一下,我这边运行环境是Python 3.10.12、pandas 2.0.3、matplotlib 3.7.2、seaborn 0.12.2。不同版本之间API可能有细微差别,如果你用的是更新版本而某个函数报错,优先查对应版本文档,别盲目照抄网上旧代码。
2.2 用一份含脏数据的示例集走通全流程
为了让整个工作流有实物可操作,我构建了一份模拟"某电商平台商品订单表"的DataFrame。它包含了真实数据里最常见的几类脏数据:缺失值、重复记录、统一字段里的多种格式、异常极端值、单位不一致的列。这份数据在接下来的章节里会反复使用:
python复制import pandas as pd
import numpy as np
np.random.seed(42)
n = 500
df = pd.DataFrame({
"订单ID": [f"ORD-{i:04d}" for i in range(1, n + 1)],
"商品类别": np.random.choice(
["数码", "服装", "食品", "家居", "图书"],
size=n,
p=[0.3, 0.25, 0.2, 0.15, 0.1]
),
"单价": np.round(np.random.uniform(10, 2000, n), 2),
"销量": np.random.poisson(lam=3, size=n),
"订单金额": np.round(np.random.uniform(30, 5000, n), 2),
"订单日期": pd.date_range("2024-01-01", periods=n, freq="h")
.strftime("%Y-%m-%d %H:%M:%S"),
"支付状态": np.random.choice(
["已支付", "未支付", "已退款", "已支付", "未支付", "paid"],
size=n,
p=[0.4, 0.2, 0.1, 0.2, 0.05, 0.05]
)
})
# 人为制造脏数据
df.loc[10:20, "单价"] = np.nan # 缺失值
df.loc[:5, "订单金额"] = np.nan # 缺失值
df.loc[200, "销量"] = 999 # 极端异常值
df.loc[201, "销量"] = -5 # 数值越界
df.loc[203, "订单日期"] = "2024-05一05 12:00:00" # 非法日期格式
df.loc[204, "商品类别"] = "数码 " # 尾随空格
df.loc[205, "支付状态"] = "已支付" # 与上面重复
df1 = df.copy()
df = pd.concat([df, df1], ignore_index=True) # 制造重复行
注意我故意把df和df1拼起来了,所以实际数据有1000行,其中后500行是前500行的精确副本。这种重复数据在真实场景里非常常见——数据仓库上游抽数时表关联做了笛卡尔积、爬虫脚本重复写入、手工合并Excel时忘了去重,都会产生这种问题。
2.3 读取数据时的第一道审查
拿到数据之后,先别急着往下走。第一件事永远是看基本信息:
python复制df.info()
df.head(10)
df.describe()
df.info()会告诉我们三件事:总行数、每列的非空计数、每列的数据类型。假如一张表明明有1000行,订单金额列的非空数却只有994,说明有6个缺失值,那么后面所有关于"订单金额"的统计都要小心。df.head(10)是让你用肉眼快速浏览前几行,看看字符串列里有没有乱码、数字列里有没有千位分隔符混入、日期列是不是一个统一格式。df.describe()输出的是数值列的分布概况,此时如果发现销量列的max是999、min是-5,就说明这一列有脏数据,后面的可视化必须处理。
提示:
df.info()一个非常有用的参数是show_counts=True,在pandas 2.0中默认就会显示非空计数。如果你用早期版本,请手动加上这个参数,否则看不出缺失情况。
3. 数据预处理五步走:从审查到清洗的实操链路
3.1 缺失值识别与处理策略
缺失值是数据预处理里最早暴露、也最容易处理不当的问题。第一步是量化看看缺失到底有多严重:
python复制missing = df.isnull().sum()
missing_ratio = missing / len(df)
print(pd.DataFrame({"缺失数量": missing, "缺失占比": missing_ratio}))
missingno库可以非常直观地看到缺失值分布——它能生成一个矩阵图,每一行是一条数据记录,每一列是一个字段,白线表示缺失位置。对于这次示例数据来说,你会发现缺失集中在第10到20行,是一个连续区块。这种区块化缺失往往意味着上游某个时段的生产系统故障,而不是随机丢失。看清这个模式,对你决定"是直接删除还是插值填充"非常重要。
填充策略要看字段的业务含义。均值为数值字段,缺失率在1%~2%这个量级时,直接删除这些行损失不大;但如果是某个业务关键字段缺失率超过30%,直接删除会导致样本严重有偏,此时需要认真考虑填充逻辑。
我用几种不同方式处理缺失值:
- 单价的缺失用中位数填充,因为单价常呈右偏分布,均值容易被大额订单拉高,中位数更稳健;
- 订单金额的缺失则根据"单价×销量"推导出来,这比任何统计填充都精确,因为业务逻辑天然存在;
- 如果实在没有可推导的字段,再退回到中位数或众数填充。
python复制# 用中位数填充单价缺失
price_median = df["单价"].median()
df["单价"] = df["单价"].fillna(price_median)
# 订单金额缺失:从单价和销量推导
mask_amount_na = df["订单金额"].isna()
df.loc[mask_amount_na, "订单金额"] = (
df.loc[mask_amount_na, "单价"] * df.loc[mask_amount_na, "销量"]
)
3.2 重复值检测:别让同一条记录出现两次
python复制print(f"重复行数量: {df.duplicated().sum()}")
df = df.drop_duplicates().reset_index(drop=True)
这里有一个值得展开的细节:df.duplicated()默认是全列完全一致才算重复,但实际业务里更常见的是"主键重复"。比如订单ID一样,但其他字段因为更新产生了差异,这种重复df.duplicated()检测不出来。你需要根据业务定义主键:
python复制# 以订单ID为判断依据
print(f"订单ID重复数量: {df.duplicated(subset=["订单ID"]).sum()}")
对于"订单ID完全重复但其他列不完全一致"的情况,处理策略通常是保留最新一条,或者把多行合并成一条。具体怎么做取决于你的业务目标,但至少要去确认——否则你在可视化时画出的图会把重复订单的金额算两遍,结果直接虚高。
3.3 数据类型修正与格式统一
df里最明显的格式问题是订单日期列:大部分是2024-01-01 00:00:00这种标准格式,但第203行是2024-05一05 12:00:00(注意"一"是全角破折号),pandas把它全部解析成了字符串。解决思路是使用pd.to_datetime统一转换,并设置errors="coerce"让无法解析的值变成NaT:
python复制df["订单日期"] = pd.to_datetime(df["订单日期"], errors="coerce")
print(df["订单日期"].isna().sum())
运行之后会看到有1个NaT,说明这条记录日期是非法值。此时的处理方式取决于这个字段在后续可视化里重不重要,如果只是用来做趋势分析且只有一条非法值,直接删除该行即可;如果这个字段是核心分析维度(比如按周聚合),就需要去上游排查为什么会出现全角字符,把源头修掉。
字符串列的格式问题同样不容忽视。支付状态里有"已支付"和"paid"两种写法,商品类别里有"数码"和"数码 "(尾随空格),这些不统一会直接导致df["商品类别"].value_counts()输出出现两条看起来一样实则不同的类别——画饼图时你会看到两个相邻的扇区。
处理方法是先strip再去重映射:
python复制# 去除首尾空格
df["商品类别"] = df["商品类别"].str.strip()
# 统一状态值映射
status_map = {
"paid": "已支付",
"Paid": "已支付",
"待付款": "未支付",
"refunded": "已退款",
}
df["支付状态"] = df["支付状态"].replace(status_map)
很多人会忽略str.strip()这一步,但真实数据的字符串列几乎永远带着看不见的空格,这是一个必须养成的习惯。
3.4 异常值检测:别盲目删除,先判断是噪声还是信号
异常值处理是整个预处理阶段最需要行业经验的一步。销量列里出现了999和-5,前者可能是录入错误,后者可能是退货抵消,但也可能意味着业务上的批量采购或者赠品发放。不要一看到异常值就删,正确的流程是:先用可视化识别异常值的位置,再结合业务含义判断是否真的异常。
可视化识别异常值用箱线图:
python复制import matplotlib.pyplot as plt
# 中文字体设置
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].boxplot(df["销量"].dropna())
axes[0].set_title("销量(含异常值)")
axes[1].hist(df["销量"].dropna(), bins=30, edgecolor="white")
axes[1].set_title("销量分布(含异常值)")
plt.show()
箱线图会把999和-5标成独立的小圆圈,一眼就能看到。但箱线图本身不能告诉你它是不是真异常,还需要结合业务规则。比如:销量不可能为负数,所以-5一定有问题;但999要看业务场景,如果没有"大促""批发"之类的背景,很可能是测试数据。
常用处理策略:
- 业务规则明确的,如销量为负,直接置为
NaN或剔除; - 超出3倍标准差的极值,先暂时剔除并单独记录,留着作对比分析;
- 如果在后续建模中使用,可以考虑做"截尾处理"——将超过99%分位数的值缩放到99%分位数处,保留信息而非删除样本。
3.5 数据标准化与衍生字段构建
预处理不只是"清理脏数据",还包括"为更好可视化而做结构变换"。数据标准化的问题在于:如果单价和销量同时放在一张图里,量纲差异会让销量柱形图在地板上,单价折线图在天花板上,两个序列无法对比。这时可以将数值列做Min-Max归一化或者Z-score标准化:
python复制from sklearn.preprocessing import MinMaxScaler, StandardScaler
# Z-score 标准化
df["销量_z"] = (df["销量"] - df["销量"].mean()) / df["销量"].std()
# Min-Max 归一化
scaler = MinMaxScaler()
df[["单价_minmax", "销量_minmax"]] = scaler.fit_transform(
df[["单价", "销量"]]
)
另一种常见操作是衍生字段:对订单日期提取年、月、日、星期几,或者从订单金额划分价格区间,这些衍生字段可以直接作为可视化的分组维度。比如"按周几统计平均订单金额"就需要提前从时间戳里提取出星期字段:
python复制df["月份"] = df["订单日期"].dt.month
df["星期"] = df["订单日期"].dt.dayofweek # 0=周一
df["小时"] = df["订单日期"].dt.hour
处理好这一步,后面画趋势图、热力图、分布图就非常顺手了。
4. 可视化工作流:从探索到呈现的完整链路
4.1 工作流各阶段的图表选型逻辑
数据预处理是"为了画出更好看的图",而可视化本身反过来也是"检验预处理效果"的手段。一个完整的可视化工作流,大致分三个阶段:
探索阶段(Exploratory):这个阶段只有你自己看,重点是多维度遍历数据,不用太在意配色。常用的图包括直方图、箱线图、散点图矩阵、缺失值矩阵。这个阶段的产出是"对数据形成感觉"。比如我画完单价直方图发现右偏严重,就知道后续做均值统计时要谨慎,用中位数更稳。
分析阶段(Analytical):这个阶段要回答具体的业务问题,比如"哪类商品销量最高""订单金额的月度趋势如何""支付状态分布是否合理"。图表以能够清晰呈现差异和趋势为主:柱状图用于类别对比、折线图用于时间趋势、箱线图用于组间分布差异、热力图用于相关性分析。
呈现阶段(Presentation):这个阶段面向业务方或读者,需要强调信息层级和信息量。图的类型选择和之前没本质区别,但字体、配色、标签、注释都需要打磨——比如加数据标签、标注关键点、使用统一的色板。
4.2 一个可复用的预处理+可视化流水线模板
我日常项目中会维护一个处理模板,把整个流程封装成函数,便于每次复用。核心思路是:数据审查、缺失值处理、类型修正、异常值标注、特征衍生、出图。每一步都返回DataFrame和图表,并把这个过程中的关键统计量记录下来,方便追溯。
python复制def preprocess_and_explore(df):
"""
一个最小可用的预处理+可视化流水线
返回清洗后的DataFrame,打印关键统计信息
"""
print("===== 1. 初始形态 =====")
print(df.shape)
print(df.info())
print("===== 2. 缺失值 =====")
missing = df.isnull().sum()
print(missing[missing > 0])
print("===== 3. 重复值 =====")
print(df.duplicated().sum())
print("===== 4. 去重 =====")
df = df.drop_duplicates().reset_index(drop=True)
print(df.shape)
print("===== 5. 类型修正 =====")
for col in df.columns:
if df[col].dtype == "object":
df[col] = df[col].str.strip()
print("字符串列strip完成")
return df
df_clean = preprocess_and_explore(df)
这个函数看起来很简单,但它已经把最基础的数据体检工作自动化了。实际应用时你可以在===== 5 =====后面继续加填充、异常值标记、衍生字段等步骤。这样做最大的好处是:项目里每个人拿到新数据都能先跑一遍体检,不用靠口头沟通"我看一下数据先"。
4.3 探索性绘图的最佳实践与自查方法
探索阶段的绘图,我总结了一个"三图起步"方法:每拿到一组干净的数值型字段,先画直方图看分布形态,再画箱线图看离群点,最后画散点图看变量关系。比如对单价和销量这两个字段:
python复制fig, axes = plt.subplots(2, 2, figsize=(12, 8))
axes[0,0].hist(df["单价"], bins=30, edgecolor="white")
axes[0,0].set_title("单价分布")
axes[0,1].boxplot(df["单价"])
axes[0,1].set_title("单价离群点")
axes[1,0].scatter(df["单价"], df["销量"], alpha=0.3)
axes[1,0].set_title("单价 vs 销量")
axes[1,1].hist(df["销量"], bins=20, edgecolor="white")
axes[1,1].set_title("销量分布")
plt.tight_layout()
plt.show()
画完之后要做的自查有四点:
- 分布形态是否合理,比如销量是否呈右偏分布,是否还有异常尖峰;
- 离群点是否已经按预期被处理,箱线图里不应该再看到
999和-5; - 散点图里的点是否呈现某个可解释的模式,比如销量随单价上升而减少之类的负相关;
- 坐标轴范围和刻度是否自然,如果出现大片空白或某个bin异常高,就需要回头检查数据。
不要小看自查这一环。我曾见过有人在预处理阶段把异常值删得干干净净,但画图时忘了重新设置xlim,残留的空bin还在图上杵着,汇报时被业务方问得哑口无言。可视化是给人看的,图能通过"视觉审查"才算真正完成。
4.4 面向结果呈现的图表优化工作流
探索阶段的图是给自己看的,到了呈现阶段就需要精雕细琢。我在呈现阶段的固定套路包括以下几步。
第一,统一配色和风格。seaborn内置的darkgrid和whitegrid主题都不错,但如果在企业汇报里,建议用一套与公司VI一致的品牌色。seaborn的set_palette可以一键切换全局色板:
python复制import seaborn as sns
sns.set_theme(style="whitegrid", palette="Set2")
第二,为图表添加注释和标签。matplotlib里用ax.annotate()标出关键点(比如峰值、拐点、异常值),比光秃秃的线图信息量大得多。比如画月趋势折线图时,在最高点标注"春节促销峰值",读者一眼就能get到图表想传达的信息。
第三,输出尺寸和分辨率要适配发布渠道。博客或公众号用figsize=(10,6)、dpi=150足够;PPT投屏可以更大;论文则一般要求矢量图:
python复制fig.savefig("output.png", dpi=200, bbox_inches="tight")
保存时务必用bbox_inches="tight",否则中文标签或标题经常被截掉一部分。
5. 把工作流沉淀成项目模板:一套可复用的习惯
5.1 从零到一搭建数据处理项目时的习惯
习惯了"先预处理再可视化"的流程之后,你会发现项目推进顺畅很多。我再分享一些建立项目模板时养成的好习惯。
项目目录建议按这样的结构组织:
code复制project/
├── data/
│ ├── raw/ # 原始数据,只读
│ ├── processed/ # 清洗后的数据
│ └── output/ # 图表和导出结果
├── notebooks/ # 探索性分析Notebook
└── scripts/ # 预处理、可视化等正式脚本
这样分层的意义在于:原始数据永远不改,所有处理都产生新文件,任何一步出问题都能回溯。我在实际项目里经常需要对比"预处理前"和"预处理后"两张图,如果原始数据被覆盖了,对比就无法进行。
第二个习惯是给预处理脚本加日志。不一定要上logging模块,简单用print记录每次操作的删除行数、填充值、转换类型就够用。这样最后交付时你才能说清楚"我一共去掉了18行重复数据、用中位数填充了11个缺失值、识别出3个异常值并截尾处理"。做数据分析不能只给结论,不给过程。
第三个习惯是每张图都保存两个版本:一个带图标和注释的"分析版",一个不带任何额外装饰的"数据版"。前者用于汇报,后者用于后续拼接或复用,省的哪次想换一个主题色还得重新跑一遍代码。
5.2 工作流延伸:从静态图到交互式可视化
预处理完成后,静态图只是起点。如果数据分析的结果要交给业务方自己去探索,我更倾向于生成交互式图表。plotly和streamlit是最常用的两个工具,它们能直接复用pandas的DataFrame,几乎不需要额外改造。
python复制import plotly.express as px
fig = px.scatter(
df, x="单价", y="销量", color="商品类别",
size="订单金额", hover_data=["订单ID"]
)
fig.write_html("output/scatter_interactive.html")
交互式可视化在数据审查阶段也很有用——鼠标悬停可以看到具体订单ID,框选异常点可以直接看到这群样本的其他字段特征。作为预处理流程的最后一道防线,交互式探索经常能发现静态统计量里看不出的问题。
5.3 我在多个项目里反复踩过的坑
最后集中分享几个实战中反复踩过的坑,希望你能绕开。
第一个坑:inplace=True用得太随意。很多人习惯df.dropna(inplace=True),但如果某个环节操作失误,原始DataFrame已经被改了,无法回溯。我现在的习惯是尽量不使用inplace=True,而是df = df.dropna(),保持所有中间结果可追溯。
第二个坑:日期解析时忽略时区和格式。如果你的数据是UTC时间,而业务看的是北京时间,不加tz_convert("Asia/Shanghai")就直接画图,趋势线的峰值位置可能会偏差8小时,业务方一眼就看出来不对。处理日期时一定要先确认时区,再做可视化。
第三个坑:categorical类型使用不当。pandas的category类型可以节省内存、加速排序,但如果你在做分组后value_counts(),有时会多出空类别计数。遇到这种情况可以用df["列"].cat.remove_unused_categories()清理。
第四个坑:中文乱码。Linux服务器上默认没有中文字体,matplotlib画图中文全变成方块。需要先安装中文字体(如fonts-wqy-zenhei),再配置plt.rcParams,没有捷径。Jupyter里可以通过临时修改字体配置来解决,但部署到服务器时要记得同步处理,这一步很容易漏。
预处理和可视化工作流看起来步骤很多,但真正跑通几遍之后就会变成肌肉记忆。我现在接到一张新表,已经不需要刻意去想"应该先做哪步",而会自动按照"体检-清洗-探索-优化-呈现"的节奏推进。随着你处理的数据集越来越多,你会越来越认同这句话:可视化是数据集的体检报告,而预处理是让这份报告真正可信的前提。
