上周帮一位朋友处理了一套订单数据,12 万行、13 个字段,内容不算复杂,无非是算一下各品类销售、各区域成交情况。真正动手做的时候才发现,大部分时间没有花在画图上,而是花在了清洗上。金额列带"¥"和千分位,日期列混着字符串和空值,同一个支付状态竟有"已付款""paid""支付完成"三种写法。这个标题"使用Pandas进行数据分析:从数据清洗到可视化"把完整链路写得很直白,但只有亲手做过的人才知道,链路前面那两个字"清洗"占了整个项目至少一半的工作量。
这篇文章我打算用一个完整的订单分析流程为主线,从拿到 CSV 文件开始,到输出可直接汇报的图表为止,把 Pandas 里最常用的数据清洗手法、分组聚合逻辑和内置可视化能力串起来。适合刚学完 Pandas 基础、想知道"这些函数到底怎么配合使用"的读者,也适合那些每天跟脏数据打交道、想换一套更系统处理思路的同行参考。
1. 拿到数据先别急着做图:先建一份数据探查清单
很多人拿到数据的第一反应就是立刻 groupby 然后画图,这个习惯我劝你改掉。数据质量没确认之前,任何统计结果都可能是错的,而且错得悄无声息。
1.1 读取后的第一眼:shape、dtypes、info
读取动作本身就有讲究。我用的是 pd.read_csv("orders.csv"),但如果你已经知道某些列是日期,最好在读取时就指定 parse_dates=True 或直接列名,省得后面再转。读取之后不要急着 head() 完事,我习惯先看三个东西:
python复制import pandas as pd
import numpy as np
df = pd.read_csv("orders.csv", parse_dates=["order_time", "pay_time"])
print(df.shape) # (120000, 13)
print(df.dtypes)
print("列名:", df.columns.tolist())
shape 告诉你数据规模,dtypes 告诉你每一列的类型,columns.tolist() 则强制你从头到尾扫一遍字段名。这一步能发现很多隐患:比如"金额"列是 object 类型,比如"用户ID"被读成了浮点数,比如日期列变成了 datetime64 而另一列时间还是字符串。这些问题现在不改,后面每个统计都会踩雷。
df.info() 也是我必跑的,它会把每一列的非空数量、内存占用一次性列出来。非空数量一旦出现明显低于总数的列,说明缺失值不会是零星的个位数,而可能是大面积的字段空缺,这直接决定后面用哪种缺失值策略。
1.2 值分布与缺失概览:describe 和 isna 的配合使用
探查的第二层是看分布。数值列用 describe(),分类列用 value_counts(),缺失情况用 isna().sum()。这三个命令配合起来,基本能在一分钟内定位大部分问题。
python复制num_cols = df.select_dtypes(include=["float64", "int64"]).columns
print(df[num_cols].describe().T)
cat_cols = df.select_dtypes(include=["object", "category"]).columns
for col in cat_cols:
print(col, ":", df[col].nunique(), "个唯一值")
print(df[col].value_counts(normalize=True).head())
print("缺失情况:")
print(df.isna().sum())
我那个朋友的数据里,quantity 列的最小值是 -3,明显不是正常订单;pay_time 缺失了 2000 多行;status 列的 value_counts 里同时出现了三种类似但不同的写法。这些都是 describe 和 value_counts 一眼就能看穿的问题。如果跳过这一步,负数的购买数量会直接拉低平均客单价,缺失的支付时间会导致后续所有按时间分组的统计口径出错。
1.3 盘点列名与索引:清洗前必须理顺的两个细节
列名检查非常容易被忽略。CSV 里字段名偶尔带着空格、中英文混排甚至多个表头行,Pandas 读进来之后列名可能就变成了" 订单号 "或者"Unnamed: 4"。我的习惯是拿到数据第一步就把列名统一成小写英文或固定规范:
python复制df.columns = df.columns.str.strip().str.lower().str.replace(" ", "_", regex=False)
索引也是同理。默认 RangeIndex 通常没问题,但如果你用 set_index 把订单号设成了索引,后面所有 groupby 和 merge 行为都会变化。我建议清洗阶段暂时保持默认整数索引,等所有清洗动作完成、确认没有重复和缺漏之后,再考虑是否把业务主键设置成索引。整数索引在 df.loc 和 df.iloc 混用时更不容易出错。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据清洗的五大典型场景与对应套路
探查阶段结束,你会对数据整体情况有数。接下来就是让数据"可分析"的关键步骤。我把清洗任务拆成五类:缺失值、类型转换、重复值、异常值、值重编码。每类都有固定的决策逻辑,照着走一遍基本不会漏。
2.1 缺失值:先分清"不应该缺"和"真没有"
缺失值处理最容易犯的错,是不管三七二十一把所有空值都 fillna(0)。你得先问自己一个问题:这一行的缺失,在业务上意味着什么?
我处理那批订单时,pay_time 缺失有两种情况:一种是订单尚未支付,另一种是订单状态显示"已支付"但支付时间为空——后者就是数据采集的问题,需要修复。我的处理思路是分条件处理,而不是全局填充:
python复制# 已支付但缺少支付时间:用订单时间兜底
mask_bad = df["pay_time"].isna() & (df["status"] == "已支付")
df.loc[mask_bad, "pay_time"] = df.loc[mask_bad, "order_time"]
# 未支付订单:保留缺失,后续分析按状态区分
df.loc[df["status"] != "已支付", "pay_time"] = np.nan
再比如 user_id 缺失,如果业务上允许游客下单,那缺失是合理的,分析时就单独拉出一个"匿名用户"分组,而不是删除或填充。缺失值处理的本质不是填数值,而是用业务语义给每一行一个合理的归类。
2.2 类型转换:字符串日期、千分位金额和分类字段
类型转换是清洗里最琐碎但最不能省的一步。我见过太多人在字符串日期上硬排序,结果"2025-02-01"排在"2024-12-01"前面,因为字符串按字典序比较。日期统一用 pd.to_datetime,但要指定 format,否则长表格下性能会很差:
python复制df["order_time"] = pd.to_datetime(df["order_time"], format="%Y-%m-%d %H:%M:%S")
df["pay_time"] = pd.to_datetime(df["pay_time"], format="%Y-%m-%d %H:%M:%S", errors="coerce")
这里 errors="coerce" 会把无法解析的值变成 NaT,后续统一处理,而不会因为一行脏数据让整个程序抛异常。
金额列的清洗更典型。原始数据里金额是 "1,280.50" 或 "¥1,280.50" 这样的字符串,必须先清除符号和千分位再转浮点:
python复制df["amount"] = (
df["amount"]
.astype(str)
.str.replace("¥", "", regex=False)
.str.replace(",", "", regex=False)
.astype(float)
)
分类字段如果唯一值有限,转成 category 类型能大幅节省内存,后续 groupby 速度也会更快。这一步在 12 万行时差异不明显,但当你面对千万级数据时,category 类型可以砍掉将近一半内存。
2.3 重复值:判断依据比去重动作更重要
去重不是简单调一次 drop_duplicates() 就结束。关键是先想清楚"重复"的判定依据是什么。订单表里,order_id 是唯一键,一单一行,所以重复判定应该用 order_id。但如果两个订单号不同、内容和金额完全一样,那不是重复,有可能是两笔真实订单。
我一个比较稳妥的排查方式是先看全部重复,再决定保留哪一行:
python复制dup_mask = df.duplicated(subset=["order_id"], keep=False)
print(df[dup_mask].sort_values("order_id").head(20))
df = df.drop_duplicates(subset=["order_id"], keep="last")
keep=False 会标出所有参与重复的行,方便你肉眼检查重复到底是什么造成的。出现过一种情况:上游脚本重复跑了两天,每天晚上追加同一批全量数据,导致同一订单号出现多条记录,多出来的那条其实是昨天已经处理过的旧快照。这种情况下 keep="last" 保留最新记录更合理。如果你直接用 keep="first",很可能会留下旧数据。
2.4 异常值:用 IQR 和业务口径联合决策
异常值最麻烦,因为它不完全是一个统计学问题。单纯用 describe 看到 quantity 最小值为 -3,第一反应就是删掉,但删除之前应该先查一下这些负数订单长什么样。我见过很多次负数来自退款订单,业务上反而需要保留。
对于明显的数值异常,我常用 IQR 方法先标记,再结合业务看。IQR 的原理简单说就是把数据按四分位切成四段,用 Q3 + 1.5 倍四分位距和 Q1 - 1.5 倍四分位距作为边界,超出边界的视为离群点:
python复制Q1 = df["quantity"].quantile(0.25)
Q3 = df["quantity"].quantile(0.75)
IQR = Q3 - Q1
lower = Q1 - 1.5 * IQR
upper = Q3 + 1.5 * IQR
outliers = df[(df["quantity"] < lower) | (df["quantity"] > upper)]
print(outliers.shape)
print(outliers["quantity"].describe())
这里我不能直接说 "把 outliers 删掉",因为离群点可能代表真实的大额批发订单,也可能是数据录入错误。我的做法是先给数据加一列 is_outlier,清洗阶段保留这些行,分析阶段再单独统计一次"含离群点"和"不含离群点"的结果。如果两种结果差异巨大,说明异常值对业务结论影响显著,需要找上游确认;如果差异不大,就在报告里注明剔除口径即可。异常值处理的最终目标是让结论稳健,而不是让数据好看。
2.5 值重编码与口径统一:清洗的隐蔽环节
值重编码是很多人意识不到的一项清洗工作。同一个语义在不同系统里可能有多种写法,比如前面提到的"已付款""paid""支付完成"。如果你在 groupby 时才遇到这类问题,往往会得到三个互相独立的分组,整个统计结论被撕碎。
我的做法是建立映射字典统一口径:
python复制status_map = {
"已付款": "已支付",
"paid": "已支付",
"支付完成": "已支付",
"未付款": "未支付",
"等待支付": "未支付",
"cancelled": "已取消",
"退款": "已取消",
}
df["status_std"] = df["status"].astype(str).map(status_map)
df["status_std"] = df["status_std"].fillna("其他")
字段统一之后,后面所有分析都用 status_std,而不是原始 status。省份字段同理,省份名有时候带"市"有时候不带,有时候是全称有时候是简称,统一映射成标准区域码会省掉后续大量麻烦。清洗到这一层,数据才算真正"可分析"。
3. 分组聚合是把清洗结果变成业务结论的关键一环
清洗完成之后,Pandas 最出彩的部分就来了:分组聚合。很多人觉得 groupby 不就一个函数吗,实际用起来才发现它有好多细节,写错了结果差别巨大。
3.1 groupby 的常用写法和两个容易踩的坑
我最常用的按类目统计总收入的写法是:
python复制category_sales = (
df.groupby("category")["amount"]
.sum()
.sort_values(ascending=False)
)
print(category_sales.head(10))
这段代码简单直接。但有两个坑值得单独说。
第一个坑:groupby("category")["amount"].sum() 返回的是一个 Series,索引是类目名。很多人会拿它去做后续的 merge 或者 pivot,却发现不能按预期对齐列名。这时需要加 reset_index() 把它变成 DataFrame,或者直接在 groupby 时用 as_index=False。我习惯在统计结束后统一 reset_index(),列名更清晰。
python复制category_sales = (
df.groupby("category", as_index=False)["amount"]
.agg(total=("amount", "sum"), order_count=("amount", "count"))
)
第二个坑:agg 里面传元组时,如果元组的第一个元素是列名,有可能会触发歧义警告。尤其是当你的列名恰好也包含 "amount" 时,某些 Pandas 版本会提示你使用元组带列名的写法。稳妥的做法是先用 df.groupby("category")["amount"].agg(["sum", "count", "mean"]) 得到 MultiIndex 列,再通过 reset_index() 后重命名列。多练几次,你就知道不同写法在列名上的差异了。
3.2 pivot_table:一次搞定多维度交叉统计
groupby 适合一维或两维分组,但当你需要"类目 × 区域 × 月份"这样一个交叉矩阵时,pivot_table 是更顺手的选择。它本质上是 GroupBy + 重塑,但语义更贴近透视表,写起来也更直观:
python复制pivot = df.pivot_table(
index="category",
columns="province_group",
values="amount",
aggfunc="sum",
margins=True,
margins_name="总计",
fill_value=0,
)
print(pivot.head())
margins=True 会在行列末尾各加一个总计,非常适合汇报场景。fill_value=0 则把没有成交的交叉位置填成 0,避免统计结果里全是 NaN 看着心慌。pivot_table 出来后如果再配一行 pivot.style.background_gradient(cmap="Blues"),直接在 Jupyter 里就是一个可以直接截图的透视表格。
不过 pivot_table 也有它的限制:数据量大的时候速度明显比 groupby 慢,而且它不太适合再做复杂的后续计算。我的习惯是:需要给业务看的交叉表用 pivot_table,需要继续加工计算的内部过程尽量用 groupby + reset_index。
3.3 时间维度的滚动与同比环比计算
有了干净的日期字段之后,时间维度分析就顺理成章。最常用的是按月汇总后计算环比,也就是本月相对上月的增长率:
python复制df["pay_month"] = df["pay_time"].dt.to_period("M")
monthly = df.groupby("pay_month")["amount"].sum()
monthly_growth = monthly.pct_change().fillna(0)
pct_change() 会默认按当前顺序计算相邻两个月的增长率,fillna(0) 是为了把第一个月没有上期可比值的 NaN 填成 0。这里有个隐含的坑:如果你的日期列不是单调递增,pct_change 就会算出错乱的结果。所以在做这类计算之前,务必先 sort_index() 或 sort_values("pay_month")。
再看一个用户维度的场景。如果想看每个用户在最近两个月的购买金额变化,可以在 groupby 之后用 diff(1) 或 shift(1) 来计算同用户相邻订单的金额差:
python复制df["amount_diff"] = (
df.sort_values("order_time")
.groupby("user_id")["amount"]
.diff(1)
)
这个写法的意思是:把数据按订单时间排序后,按用户分组,对组内金额取与上一行之差。结果里第一个订单因为没有上一行,会出现 NaN,后续你想统计"有多少用户第二次购买金额比第一次高"就很简单了。
4. 用 Pandas 内置绘图完成分析与可视化的闭环
很多教程喜欢直接跳到 Matplotlib 或 Plotly,但我在实际项目里经常先用 Pandas 自带的 plot 接口快速出图,因为它和 DataFrame 结构结合得最紧密,几行代码就能看到趋势。做完初步探索,确认结论方向之后再决定要不要用其他库精修。
4.1 为什么我推荐先从 Pandas 自带 plot 开始
Pandas 的 df.plot() 底层是 Matplotlib,但接口更贴近数据。你想要一个按月份收入趋势的折线图,只需要:
python复制monthly.plot(kind="line", figsize=(12, 6), title="月度销售额趋势")
一行代码,不需要手动 plt.plot(x, y),也不用考虑数据转换。再比如类目销售额对比,用柱状图:
python复制category_sales.plot(kind="barh", figsize=(10, 8))
横向柱状图在类目名称较长时明显比纵向图更易读。plot 的 kind 参数支持 line、bar、barh、hist、box、kde、scatter、area 等,日常分析 90% 的图形类型都覆盖了。直接做探索性分析足够了。
4.2 根据问题类型选择图:趋势、对比、分布
选图的核心逻辑不是"哪个好看",而是"你想回答什么问题"。想说明一段时间内指标上升还是下降,用折线图;想对比几个类目谁高谁低,用柱状图;想展示某个指标在分组间的离散程度和离群点,用箱线图;想了解单变量的分布形状,用直方图或密度图。
举个例子,如果我想看不同类目的客单价分布差异,用箱线图最直接:
python复制df.boxplot(column="amount", by="category", figsize=(12, 6))
plt.xticks(rotation=45)
这一张图能把类目间的中位数、四分位距、离群点全部呈现出来,比几行统计表更有冲击力。之前我帮朋友做汇报时,就靠这张图一句话说明了"数码类目客单价离散度最大,存在大量高值订单,不能只看均值"。
4.3 折线、柱状、箱线、热力图的落地代码
下面是一套我从月度趋势到相关分析常用的绘图代码,可以直接套用:
python复制import matplotlib.pyplot as plt
# 中文字体设置
plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"]
plt.rcParams["axes.unicode_minus"] = False
# 1. 折线图:月度收入趋势
monthly.plot(kind="line", marker="o", figsize=(12, 6))
plt.title("月度销售额趋势")
plt.ylabel("销售额")
plt.grid(alpha=0.3)
# 2. 柱状图:类目销售额 Top10
top10 = category_sales.head(10)
top10.plot(kind="barh", figsize=(10, 8))
plt.title("类目销售额 Top10")
plt.xlabel("销售额")
# 3. 箱线图:类目客单价分布
df.boxplot(column="amount", by="category", figsize=(12, 6))
plt.xticks(rotation=45)
plt.title("各类目客单价分布")
plt.suptitle("")
# 4. 热力图:数值特征相关性
corr = df[["quantity", "unit_price", "amount"]].corr()
fig, ax = plt.subplots(figsize=(6, 5))
im = ax.imshow(corr, cmap="RdYlBu_r")
ax.set_xticks(range(len(corr.columns)))
ax.set_yticks(range(len(corr.columns)))
ax.set_xticklabels(corr.columns)
ax.set_yticklabels(corr.columns)
for i in range(len(corr)):
for j in range(len(corr)):
ax.text(j, i, f"{corr.iloc[i, j]:.2f}", ha="center", va="center")
plt.colorbar(im)
热力图这块我不建议为了一个小功能额外引入 seaborn,虽然 seaborn 的 heatmap 确实更好看,但是 Matplotlib 本身画一个简单的矩阵热力图也就十行代码,足够日常使用。
4.4 中文字体与画布大小等可视化小坑
Pandas 绘图最常见的坑就是中文乱码和坐标轴文字显示异常。Matplotlib 默认字体不支持中文,所以要先设置字体。上面代码里 font.sans-serif 用了 SimHei 和 Microsoft YaHei,Mac 上可能要用 "PingFang SC" 或者 "Heiti TC",Linux 上则可能是 "WenQuanYi Zen Hei"。这个设置是根据你当前环境来定的。
还有一个小坑是 boxplot(by=...) 会默认在主图上方添加一个 title,导致标题重叠,所以我在上面用 plt.suptitle("") 把它去掉。Pandas 的 plot 接口和 Matplotlib 混用时,这类来自 by 参数的隐形标题问题很常见,如果你遇到"为什么我的图有两个标题",多半就是它。
画布大小也值得养成习惯。默认画布通常太小,坐标轴标签多了之后会叠在一起,导致看不清。我一般先 figsize=(12, 6) 起步,如果是横向柱状图,高度给到 8 甚至更高。可视化的目标是让人一眼看懂结论,不是让文字互相打架。
5. 收尾动作:导出报告、性能优化与保存代码
分析做完了,图也画出来了,剩下的工作就是把中间产物保存好。这一步看似简单,里面还是有讲究的。
5.1 清洗结果导出:CSV、Excel、Pickle 怎么选
清洗后的数据最好分两个版本保存:一个给下游继续结构化处理,一个给人看。给机器用的推荐 CSV 或者 Pickle,给业务看的推荐 Excel。我常用的导出代码:
python复制df_clean.to_csv("orders_clean.csv", index=False, encoding="utf-8-sig")
df_clean.to_pickle("orders_clean.pkl")
df_clean.head(1000).to_excel("orders_sample.xlsx", index=False, sheet_name="清洗结果")
CSV 里 index=False 可以避免把默认索引写进文件,encoding="utf-8-sig" 是为了让 Excel 打开 CSV 不会乱码。如果你后面马上还要继续分析,to_pickle 是最快的,它直接序列化整个 DataFrame,读取速度和性能都好于重新 parse CSV:
python复制df_clean = pd.read_pickle("orders_clean.pkl")
这张表再配合几个早前的汇总表,已经足够完成日常汇报。如果是正式交付,我还会再补一份数据字典,说明每个字段的清洗规则、缺失值处理方式和口径定义。这看起来麻烦,但能在后续被同事反复问"这个数怎么来的"时帮你省下大量解释时间。
5.2 大数据量下的性能优化技巧
当数据量从 12 万行变成 2000 万行时,Pandas 的一些默认行为会变得很慢。我给你几个我验证过有效的方法。
第一个是读取时显式指定 dtype 和 parse_dates,而不是让 Pandas 自己猜。字符串列如果能确定是 category,直接指定,内存占用会显著降低:
python复制df = pd.read_csv(
"orders.csv",
dtype={
"user_id": "int32",
"category": "category",
"province": "category",
},
parse_dates=["order_time", "pay_time"],
)
第二个是尽量用向量化操作代替循环。比如你想筛出所有金额大于 1000 的记录,直接用 df[df["amount"] > 1000],不要用 df.apply(lambda row: ...)。尤其是涉及字符串操作时,str.contains、str.replace 这类向量化方法比循环快一个数量级以上。
第三个是分块读取,适合内存不够时。你可以用 chunksize 分批处理,最后合并结果:
python复制total = 0.0
for chunk in pd.read_csv("orders.csv", chunksize=50000):
total += chunk["amount"].sum()
print(total)
这个方法不常用,但一旦数据超过内存就非常重要。Pandas 的设计初衷不是处理无限大的数据,chunksize 配合聚合逻辑,能让它在有限内存下完成大规模统计。
5.3 我常犯的一个低级错误:作用在副本上
最后我想单独说一下 SettingWithCopyWarning。这个警告我早期几乎每两天见一次。它的本质是你对切片后的 DataFrame 赋值时,Pandas 分不清你是在修改副本还是原对象。比如:
python复制sub = df[df["amount"] > 1000]
sub["flag"] = 1 # 可能触发 SettingWithCopyWarning
正确做法是在切片后显式加 .copy():
python复制sub = df[df["amount"] > 1000].copy()
sub["flag"] = 1
这个错误非常隐蔽,因为它不一定会报错,但在某些 Pandas 版本里会导致赋值结果丢失,从而悄悄改变统计结果。我的习惯是:只要对切片后的 DataFrame 做任何写入操作,一律先 .copy(),养成习惯之后基本再也没遇到这个警告。
另外,groupby 之后如果要对结果赋值,也注意先 reset_index() 再操作,否则容易掉进多层索引的坑里。多花一行代码,能少排查一小时。
说个我自己的体会:数据清洗和可视化看着是两个极端,但它们其实是同一条链路。清洗阶段花了力气,可视化阶段才能顺顺当当。每次拿到新数据,我都会先把探查清单跑一遍,把缺失、重复、类型、语义统一这些问题按顺序理完,再进入分析和画图。Pandas 帮你把"脏"变成"干净",但哪些清洗动作值得做、清洗到什么程度算到位,最终还是取决于你对业务的理解。做完一次完整的数据分析之后,我强烈建议你把清洗规则和探查快照一并存档,因为下一次类似的数据需求,大概率就是从这个"上轮经验"开始的。
