数据分析这个活儿,接需求、拉数据、算指标、出图汇报,看起来是四个环节,其实真正耗时间的全是中间的洗数过程。我见过太多人一上来就 df.plot,结果画出来一堆噪声,然后回过头再骂数据脏。在 Pandas 里,清洗和可视化从来不是两件事,清洗不彻底,可视化就是给错误数据做辩护。这篇就用一份模拟的销售订单数据,把从 read_excel 到出图的全流程走一遍,每一步都讲清楚为什么这么做,以及做完之后你应该能从图里读出什么。
1. 环境准备与数据读取:先把Pandas跑起来
1.1 安装Pandas与配套库,别漏了openpyxl
先把环境弄好。如果你是用 PyCharm 新建的项目,最省事的方式是直接用 pip 命令行安装:
bash复制pip install pandas openpyxl matplotlib seaborn
这里有几个细节值得注意:
- 为什么单独加 openpyxl? Pandas 读取
.xlsx文件并不是内置功能,它需要调用底层引擎。openpyxl就是最常用的 Excel 读写引擎,不装的话,pd.read_excel()会直接报错,提示你缺少依赖。读取旧的.xls格式则需要xlrd,但现在基本很少见到了。 - 不要把 matplotlib 和 seaborn 忘了。 只装 pandas 的话,DataFrame 自带的
.plot()方法用不了,因为背后依赖 matplotlib 渲染。 - PyCharm 里如果提示找不到 pandas,先检查解释器是不是选对了。很多人项目用了虚拟环境,但终端执行
pip install时装的却是全局 Python 的包,两边对不上,这个坑非常经典。
如果你在 Jupyter Notebook 里用,建议顺手执行一下 %matplotlib inline,这样图形能直接显示在页面里,不用每次调用 plt.show()。
1.2 用read_excel读入数据,先看全貌再动手
假设手上有一份销售订单明细,列包含订单日期、商品分类、商品名称、销售额、成本、区域、订单状态。这是最常见的业务表结构。读取就一行:
python复制import pandas as pd
df = pd.read_excel("sales_orders.xlsx", sheet_name="Sheet1")
拿到数据之后,第一件事不是画图,也不是做透视,而是“看全貌”:
python复制print(df.shape) # 多少行多少列
print(df.head()) # 看前五行,了解字段内容
print(df.dtypes) # 看每列的数据类型
print(df.info()) # 总览:行列数、非空值数、内存占用
print(df.describe()) # 数值型字段的基本统计量
我个人的习惯是重点看 info() 和 describe()。这两个方法能在一分钟内告诉你三件事:哪些列有缺失、哪些列的数值量级异常、哪些列的存储类型不符合预期。head() 虽然直观,但只能看到内容,看不到结构问题。
比如 describe() 如果显示销售额的 min 是负值,那这列大概率有退款或异常数据;如果 count 明显少于行数,说明存在缺失值。这一步是所有清洗动作的输入条件,判断越充分,后续处理越有针对性。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据清洗:脏数据各有各的脏法,清洗逻辑各有各的取舍
2.1 缺失值处理:删除还是填充,取决于业务含义
数据清洗的第一步通常是处理缺失值。但在动手删之前,务必要搞清楚“缺失”背后代表什么。
python复制print(df.isnull().sum())
常见的情况有三种:
- 关键字段缺失,无法修复:比如订单ID缺失,这一行无法定位业务记录,直接删除是最稳妥的。
- 数值字段缺失,有合理估计空间:比如成本缺失,但该品类有历史平均成本,可以用中位数或者平均数填充。
- 非关键字段缺失,且缺失比例很高:比如备注字段,缺失 90% 以上很正常,这种列甚至可以整个丢掉。
删行用 dropna(),填值用 fillna():
python复制# 删除订单ID为空的行
df = df.dropna(subset=["order_id"])
# 用销售成本的中位数填充缺失的成本值
cost_median = df["cost"].median()
df["cost"] = df["cost"].fillna(cost_median)
# 指定列的重要性排序后,统一删除剩余含缺失的行
df = df.dropna()
这里想分享一个心得:dropna() 默认是“只要行里任一字段为空就删除”,这在字段很多时很容易误伤。所以我会先用 subset 明确指定判断范围,只对真正影响分析的关键字段采用删除策略,其他字段能填则填。数据清洗的本质是保留信息,不是消灭行数,动不动就 dropna() 的粗暴做法会把很多有效信息一起丢掉。
2.2 重复值处理:订单表重复和三列重复不是一回事
去重之前,先明白你要用什么标准判定重复。比如订单明细表,同一个订单号下面有多行商品是正常的,如果按订单号去重,就会误删真实数据。但如果你做的是客户名单管理,那客户ID重复就是问题。
python复制# 完全相同的行去重
df = df.drop_duplicates()
# 按订单号判断重复,重复时保留第一条
df = df.drop_duplicates(subset=["order_id"], keep="first")
keep 参数有三个可取值:first 保留第一条,last 保留最后一条,False 表示全部删除。具体用哪个,取决于业务上哪条数据更可信,或者是否有更新时间字段可以帮助判断。
干这行踩过一个很实在的坑:某次处理一张五万行的订单表,一上来就全字段去重,结果去掉了几千行。后来核对原始数据发现,其中有一部分是合法订单,只是记录里用户修改过备注信息,整行并非完全一致。从那之后,我的原则就是先想清楚“唯一键”是什么,再决定去重口径。
2.3 异常值识别:describe和业务规则双管齐下
异常值不能光靠统计学方法硬找,还要结合业务规则。比如一个销售表里出现 sales = -10000,统计上它可能只是落在分布尾部的点,但业务上一眼就知道这是退款单或者数据录入错误,分析时就应该单独对待。
实操中建议分两步:
python复制# 第一步:用分布看异常发现不了的,先用业务规则过滤
df = df[df["sales"] >= 0]
# 第二步:用四分位距识别偏离谱的值
Q1 = df["sales"].quantile(0.25)
Q3 = df["sales"].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
df_outlier_candidates = df[(df["sales"] < lower) | (df["sales"] > upper)]
这里要特别提醒一句:识别出的异常值不等于要删除。异常值可能是数据录入错误,也可能是真实的极端业务(比如大客户突然下一笔巨单)。正确姿势是先筛选出来,逐条人工判断或加标记,而不是一把梭直接在原表里删掉。通常我会加一列 is_outlier 来标记,后续建模或统计口径里再决定是否排除。
2.4 数据类型转换:日期到底是不是datetime,影响超过你的想象
数据清洗里最容易被忽略的就是类型转换。大量 Excel 导入的数据,日期列读出来是字符串或者 datetime 的混搭,销售额可能带着货币符号变成 object 类型,最终导致分组聚合结果完全错误。
python复制# 日期列统一成 datetime
df["order_date"] = pd.to_datetime(df["order_date"], errors="coerce")
# 销售额转数值,遇到无法解析的置为 NaN
df["sales"] = pd.to_numeric(df["sales"], errors="coerce")
errors="coerce" 是个非常实用的参数,意思是解析失败时不要报错,而是把该单元格置为 NaN,方便后续统一处理。类型转换完再做一次 df.info() 验证,确保 order_date 是 datetime64[ns],sales 和 cost 是 float64,category 是 object 或 category。
顺便说一个很多人不知道的细节:如果某列是“是/否”这种二值字段,可以转成 astype("bool") 或者映射成 0/1,后续做分组计数会顺手很多。但如果是多分类标签,转成 category 类型能有效降低内存占用,尤其是几万行以上的数据时,df[col].astype("category") 的收益非常明显。
3. 核心字段加工与业务分析:从清洗需求反推特征工程
3.1 添加辅助列:年、月、利润率计算
清洗完成之后,下一步不是立刻可视化,而是做“字段加工”。如果要做月度趋势分析,就需要从日期里拆出月份;如果要做利润分析,就需要算利润率。这些辅助列的生成要及时,否则后面每次分析都要重复造轮子。
python复制df["year"] = df["order_date"].dt.year
df["month"] = df["order_date"].dt.month
df["year_month"] = df["order_date"].dt.to_period("M")
df["profit"] = df["sales"] - df["cost"]
df["profit_margin"] = (df["profit"] / df["sales"]).replace([float("inf"), -float("inf")], 0)
计算利润率时有一个隐藏坑:有些行销售额为 0,除法就直接产生 inf 了,不处理的话后续聚合会出一堆奇怪结果。我这里用 replace 把正负无穷替换成 0,是出于这类数据实际没有利润率的考虑。如果你的业务遇到过这种情况,你自己会选择替换成 0 还是 NaN?单纯从我自己的实践经验看,替换成 0 对后续钻取汇总更友好,替换成 NaN 则保留了“该行数据不可参与计算”的语义,取决于你是不是要单独分析这批异常业务。
3.2 分组聚合的粒度选择:是groupby还是pivot_table?
清洗和加工结束后,最常用的分析手段就是分组聚合。不过 groupby() 和 pivot_table() 都挺好用,怎么选?我个人的标准是:先看输出格式,再看是否需要同时聚合多个指标。
如果只是要“每个商品品类卖了多少销售额、多少成本”,用 groupby 就够了:
python复制category_summary = df.groupby("category").agg(
total_sales=("sales", "sum"),
total_cost=("cost", "sum"),
order_cnt=("order_id", "count"),
)
print(category_summary.head())
这里要注意:agg() 中如果传元组列表,元组第一个元素是列名,第二个是聚合函数;如果直接传多个列名 df.groupby("category")["sales", "cost"].sum() 也可以。区别在于结果列名是不是容易看懂。
但如果想同时看“品类 × 区域”的交叉表,pivot_table 更顺手:
python复制region_category = pd.pivot_table(
df,
index="category",
columns="region",
values="sales",
aggfunc="sum",
fill_value=0,
)
fill_value=0 很关键,因为透视表默认缺失值会是 NaN,但业务上“没有交易”和“缺失数据”是两回事,填 0 更符合这里的语义。
另一个容易被忽略的点是:透视表用 margins=True 可以加总计行:
python复制region_category_margin = pd.pivot_table(
df,
index="category",
columns="region",
values="sales",
aggfunc="sum",
margins=True,
margins_name="合计",
)
这样在业务汇报时,既能看每个品类的区域分布,又能直接看到品类合计和区域合计,一眼就能定位哪块业务贡献最大。
3.3 多表连接:用merge补维度,而不要靠VLOOKUP思维
真实分析场景里,明细表往往还需要关联一张区域负责人表或者商品分类映射表。Excel 时代大家习惯 VLOOKUP,Pandas 里对应的操作是 merge 或者 map。如果只是增加一列映射关系,用 map 更轻量:
python复制region_manager_map = {
"华东": "张伟",
"华南": "李娜",
"华北": "王强",
}
df["region_manager"] = df["region"].map(region_manager_map)
如果是整张维度表的关联,则用 merge:
python复制region_info = pd.read_excel("region_info.xlsx")
df_merged = df.merge(region_info, on="region", how="left")
对 how 参数的取舍也要根据业务来。默认 how="inner" 取交集,但如果明细表里有区域不在维度表里,inner 连接会直接丢掉这些订单。通常做报表时用 left 连接保证明细表行数不丢,缺失的维度信息后面再用「未知」填充即可。
4. 可视化呈现:从数据清洗到图表语言的转化
4.1 绘图前的数据形态准备:宽表还是长表?
绘图前先确认一个核心问题:你的数据现在是什么形态,目标图表需要什么形态?这个问题的优先级比任何绘图语法都高。大多数情况下,我们聚合出来的数据是长表或宽表,但图表库的 API 往往对某一形态更友好。
- 长表(tidy data):每行是一个观测,特别适合
sns.barplot、sns.lineplot这类接口。 - 宽表:每列是一个分组,适合直接调用
df.plot()的默认行为,或者做堆叠柱状图。
如果数据不是目标形态,最简单的调整就是重置索引或者行转列:
python复制# 从分组结果直接生成宽表
trend_wide = df.groupby(["year_month", "category"])["sales"].sum().unstack()
print(trend_wide.head())
这个 unstack() 会把品类变成列,月份变成索引,画多系列折线图时极其方便。如果是长表,则要反过来用 melt()。
4.2 折线图看趋势:月份与销售额的基本叙事
拿到按月汇总的数据后,画折线图几乎是必做动作:
python复制import matplotlib.pyplot as plt
monthly_sales = df.groupby("year_month")["sales"].sum()
plt.figure(figsize=(12, 5))
monthly_sales.plot(kind="line", marker="o", linewidth=2)
plt.title("月度销售额趋势")
plt.xlabel("月份")
plt.ylabel("销售额")
plt.grid(alpha=0.3)
plt.tight_layout()
plt.show()
画出来之后,你会发现许多有意思的问题:为什么某个月突然有一个断崖式下跌?是不是数据里有大量未完成订单混入了?是不是那个月成本列大面积缺失,导致按月分组时行数变少?
这种反向验证特别重要。图形不仅是汇报工具,也是质检工具。它会把你清洗时遗漏的问题,直接暴露在视觉层面。折线图的曲线如果是“锯齿状”而不是“平滑趋势”,先不要急着解释业务波动,回去看数据源,很可能就是脏数据造成的。
有个绘图细节想提醒一下新朋友:matplotlib 默认的坐标轴标签如果出现中文乱码,是因为默认字体不支持中文。建议在绘图前固定加两行设置:
python复制plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"]
plt.rcParams["axes.unicode_minus"] = False
unicode_minus 不改的话,坐标轴上的负号会显示成方块,非常影响观感。
4.3 柱状图和饼图:类别对比要选对图,顺序比颜色重要
如果是看各品类的销售额占比或对比,柱状图比折线图更合适。柱状图最需要注意的一点是排序。Pandas 绘图的默认顺序是索引顺序,不排序的话,柱子高低错落,视觉上很难读。
python复制category_sorted = category_summary.sort_values("total_sales", ascending=False)
plt.figure(figsize=(10, 6))
category_sorted["total_sales"].plot(kind="bar", color="#4C72B0")
plt.title("各品类销售总额对比")
plt.xticks(rotation=45)
plt.ylabel("销售额")
plt.show()
饼图则要慎重使用。除非你的品类数量很少(少于5个)而且份额差距明显,否则饼图的可读性是所有图表里最差的。很多人做汇报时习惯用饼图显示占比,但说实话,超过5个类别时读角度远没有读柱子的长度直观。如果非要保留占比叙事,可以先按销售额排序,取前几名合并成“其他”,再画饼图。
4.4 用Seaborn做分布分析:散点图和直方图的数据洞察
除了基本的业务汇总图,数据分析还需要理解数据本身的分布。seaborn 提供了一行代码就能画出高质量统计图的接口:
python复制import seaborn as sns
plt.figure(figsize=(8, 5))
sns.histplot(df["sales"], bins=50, kde=True)
plt.title("销售额分布直方图")
plt.show()
从直方图里能看出数据是否长尾、是否有双峰、是否有大量集中在某一个值附近的异常结构。比如订单销售额大概率是右偏分布,少数大额订单会把均值拉得很高,这种情况下计算平均值时要考虑要不要用中位数更稳健。
如果是看“销售额”和“利润”的关系,画散点图加回归线:
python复制plt.figure(figsize=(8, 6))
sns.scatterplot(data=df, x="sales", y="profit", alpha=0.5)
sns.regplot(data=df, x="sales", y="profit", scatter=False)
plt.title("销售额与利润关系")
plt.show()
如果散点图上出现一条“斜率为负”的密集点带,那很可能是成本大于销售额的亏损订单,这类样本需要回到清洗阶段检查是否包含录入错误。
4.5 多维下钻:以“品类 × 月份”热力图为例清洗后的交叉验证
热力图是非常适合做多维钻取的图表,它能在一个页面里同时呈现两个维度上的数值差异。Pandas 透视表造出来的结果,可以直接喂给 seaborn:
python复制pivot_category_month = pd.pivot_table(
df,
index="category",
columns="month",
values="sales",
aggfunc="sum",
fill_value=0,
)
plt.figure(figsize=(12, 6))
sns.heatmap(pivot_category_month, annot=True, fmt=".0f", cmap="coolwarm")
plt.title("品类月度销售热力图")
plt.show()
看热力图时,有一个非常经典的场景:某个月份某一品类颜色明显偏深或偏浅,但其他品类没有相同变化,这不是业务突变,大概率是那个品类的数据在清洗阶段丢了一部分,也就是缺失值处理或异常值过滤导致了样本量不对。这时回到数据源重新核对,看看是不是过滤条件写得过于宽松,把大量正常交易给误杀了。
5. 常见问题与排查技巧实录
5.1 “读Excel文件时报错:Missing optional dependency 'openpyxl'”
这个报错出现频率极高,原因就是安装 pandas 的时候没装 openpyxl。解决办法就是开头提到的先补依赖:
bash复制pip install openpyxl
如果是 .xls 老格式报错,提示缺少 xlrd,则安装 xlrd。如果你只是读 .xlsx,建议直接用 openpyxl 引擎,因为 xlrd 在高版本里已经不再支持 xlsx。
5.2 为什么groupby之后列名总带个括号?
很多朋友在 agg() 之后打印列名,看到的结果是 ('sales', 'sum') 这种形式,这是多级列索引导致的。如果你觉得后续取列麻烦,可以手动拍平:
python复制category_summary.columns = ["total_sales", "total_cost", "order_cnt"]
或者在 agg 之前就直接用 pd.NamedAgg 定义名称,不过让结果列非多级索引,最简单的方法还是重置列名。这个问题的根源在于 Pandas 对agg结果的列命名逻辑,群友经常来问我这个,其实看清帮助文档花两分钟就解决了,但有的时候你不去弄,下次还会遇到同样问题。
5.3 销量与销售额都是数值,为什么按“月份”分组时出现了NaN?
很大概率是日期列没有成功转换为 datetime,导致用 dt.month 时报错或全部变成 NaN。记住读取后第一步验证类型:
python复制print(df["order_date"].dtype)
如果是 object 或者混入了非标准格式的字符串,pd.to_datetime() 需要用 errors="coerce" 处理非法值,再检查转换后有多少 NaT。
5.4 交叉表里出现大量0是好是坏?
透视表里的 0 通常代表“没有交易”,这不是缺失值而是业务事实。如果你用 fillna(0) 填充,体现的是“没有发生”的语义;如果直接把 NaN 留着,在热力图里会显示为灰格,更直观地体现“没有数据”。两者没有绝对的对错,但你要明确自己要用哪种语义,避免图表误导别人。
5.5 画出图后中文全变方框
matplotlib 默认字体中文字符集缺失,需要指定中文字体。如果你在 Linux 服务器上跑,可能需要先安装中文字体包,比如 fonts-noto-cjk。在本地 Windows/macOS 上用 SimHei 或者 Microsoft YaHei 就能解决。
5.6 内存不够,几百万行数据跑不动
Pandas 处理千万行以下的数据通常没有压力,但如果你感觉卡顿,先看是不是类型存储空间过大。字符串类型的列如果只有几十个类别的枚举,可以转 category;数值列如果只需要 float32,就别让 Pandas 默认用 float64。建议读取时就指定 dtype:
python复制dtype_map = {
"category": "category",
"region": "category",
"sales": "float32",
"cost": "float32",
}
df = pd.read_excel("sales_orders.xlsx", dtype=dtype_map)
这个优化对性能的提升是立竿见影的,内存占用能直接砍掉一半。
5.7 数据清洗前后到底怎么对比最直观?
建议清洗每进行完一步,就输出一次行数和缺失值。
python复制print("清洗前:", df_raw.shape)
print("缺失值:", df_raw.isnull().sum().sum())
# 执行清洗...
print("清洗后:", df.shape)
print("缺失值:", df.isnull().sum().sum())
这样整个清洗过程像流水线一样透明,出了问题也能快速定位是在哪一步丢的行,不会在最后出图阶段才发现问题。整个数据分析流程中,清洗占用的时间精力往往超过一半,但支撑结论可靠性的恰恰是这一步。Pandas 给你的其实是“快速验证业务假设”的能力,清洗得够干净,可视化只是把干净的结论讲给别人听。真正值钱的是对数据每个坑的理解,而这份理解只能靠一次一次压榨数据来积累。
