上周五下午临近下班,某电商业务线的运营同事甩过来一个压缩包,里面是一份几十万行的订单销售明细,附带一句“帮我看下最近销售到底怎么回事,老板明天要数字”。这种场景我遇到过很多次。最初我也习惯用Excel硬扛,数据一多就卡成幻灯片,后来慢慢沉淀出一套用Python处理电商销售数据的标准动作。这篇文章就把这套完整流程拆开讲清楚,从拿到原始明细、清洗口径、计算指标,到用户和商品深度拆解、可视化输出,再到最后把脚本沉淀成可复用的小工具。整个过程都是基于某电商订单销售数据的真实案例改写,字段名和代码逻辑可以直接替换成你自己的数据。适合刚接触数据分析、会用一点Python但还没跑通过完整项目的朋友,也适合已经会写简单脚本但心里没底、容易踩坑的同学。
1. 第一眼看到订单明细时,我建议你别急着写代码
1.1 先弄清楚这张表里的每个字段在说什么
很多人拿到数据的第一反应是打开编辑器敲pd.read_csv,我一开始也这样,后来返工几次发现这是最亏的操作。很多问题不是代码写不对,而是我在读数据前根本没搞明白这份表格是怎么来的、每个字段代表什么、什么该信什么不该信。
比如我这次拿到的数据长这样:订单号、下单时间、支付时间、商品ID、商品名称、类目、数量、单价、支付金额、优惠券分摊、运费、退款金额、订单状态、支付状态、用户ID、渠道来源。看起来字段很全,但真正要算的时候,每个字段都可能埋雷。
先别管单号还是金额,打开文件前做三件很基础的事:
第一,问一句“这份数据从哪个后台导出、时间范围是哪段、含不含退款订单、有没有测试单”。运营同事往往脑子里的数据和导出文件里的数据不是一回事,他们默认“你知道”,实际上没人知道。
第二,看字段说明。有些后台导出的Excel首个工作表会带一页说明,或者文件名里带着“已付款订单”这类关键字。没有说明的,至少打开看前几行,核对一下枚举值大概有哪些。
第三,确认金额单位。有的是元,有的是分,还有的订单金额和实付金额混在一起。这条不确认,后面所有汇总数都是错的。
我用代码做了最基础的结构检查:
import pandas as pd df = pd.read_csv(r"某电商订单数据.csv", encoding="utf-8-sig") print(df.shape) # 看看多少行多少列 print(df.columns.tolist()) # 打全部字段名 print(df.head(10).to_string()) # 肉眼看前10行 print(df.dtypes) # 看字段类型,金额字段是否被识别成字符串第一次运行发现“用户ID”被读成了浮点型,说明原始数据里有空值;部分Excel导出时把长数字ID强制变成了科学计数法,读出后精度还会丢。这时候不要傻傻改代码继续跑,先处理源头,要么让业务方重新导出,要么用参数dtype={"用户ID": str}强制按字符串读取。
1.2 用describe()和isnull()给数据做一次“体检”
确认完字段,数据体检是下一步。我这里说的体检不是一句简单的df.describe()就完事,而是要针对电商订单数据的特殊性做几项专门检查。
第一个检查是订单状态和支付状态的取值分布。电商后台经常有“待付款、已支付、已取消、已退款、部分退款”等状态,导出的明细未必帮你过滤好。你得先知道这个表里混了多少不该参与统计的订单。
for col in ["订单状态", "支付状态", "渠道"]: if col in df.columns: print(col) print(df[col].value_counts(dropna=False).head(20))第二个检查是数值字段的边界值。比如“商品数量”有没有0或者负数,“支付金额”有没有异常大的值。“退款金额”可能有大量空值或0,这些都要先搞清楚是正常还是异常。
第三个检查是时间字段的格式。如果后台导出的是字符串,比如"2024-11-05 14:32:11"或者"20241105143211",直接pd.to_datetime可能报错,也可能需要指定格式。如果时区信息不对,后面趋势聚合直接偏掉一天。
做完一轮体检,我一般会把下面的信息整理成一张表发给对方确认,不然后面算完对方说口径不对,谁都不舒服:
| 字段 | 取值类型 | 是否参与统计 | 备注 |
|---|---|---|---|
| 订单号 | 字符串 | 是 | 一个订单可能多行子订单 |
| 订单状态 | 已支付/已取消/已退款 | 仅保留已支付 | 待确认测试单规则 |
| 支付时间 | datetime | 是 | 需要确认时区 |
| 支付金额 | float | 是 | 单位:元 |
| 退款金额 | float | 是 | 全额退款和部分退款分开看 |
这套节拍对任何数据集通用,不会因为项目换一个就作废。数据分析的绝大部分错误,回想起来全是“数据理解错位”,而不是计算本身出错。
2. 销售额、GMV、退款率:先把口径和业务方对齐再算
2.1 为什么同一份数据会算出两种结果
这是我带过的所有数据分析项目里最容易扯皮的地方。同样一份订单明细,有人算出GMV一千两百万,有人算出八百万,两个数放一起谁都不敢信。原因通常不是计算错误,而是口径不统一。
电商场景里常见的口径分歧有这么几类:
- 算不算未支付订单。GMV在有些团队里包含已下单未支付的单子,有些则只算已支付。你说不清老板要的是哪个,就先按两者都算,再备注差异。
- 算不算退款订单。全额退款算不算销售额?部分退款扣掉退的部分还是只按实付算?这个影响极大,一个高退款类目能直接让销售额虚高两成。
- 用支付时间还是下单时间。跨月场景下这两者能差出几天到几周,月底冲业绩时尤其明显。
- 父子订单是否合并。一个订单可能拆成多个商品行、多个子订单,如果直接对“支付金额”列求和,很可能把同一个订单的金额重复计算了。
- 是否扣除优惠券分摊和运费。日常分析里,优惠券不一定是按订单维度全减,有时分摊到SKU层面,直接取实付字段更省心。
我这次采用的简化口径是:统计暂以“订单支付时间和实际支付金额”为基础,只保留支付状态为“已支付”的订单;包含运费,不剔除部分退款,但单列退款金额和退款率。先把这一套写法明确写进代码注释和输出报告里,后面怎么讨论都有据可依。
2.2 写一套带过滤条件的基础指标计算
口径定了,计算就变成按规则编码的问题。我的建议是不要一个sum就完事,把过滤条件显式写在一个布尔列里,方便复查和复用。
# 统一口径:只保留已支付订单 valid = df[df["支付状态"] == "已支付"].copy() # 实际支付金额:支付金额 - 退款金额,退款部分后续单独分析 valid["实际支付金额"] = valid["支付金额"] - valid["退款金额"].fillna(0) # 如果订单表一个订单有多行,先按订单号聚合 order_level = valid.groupby("订单号").agg( 用户ID=("用户ID", "first"), 支付时间=("支付时间", "max"), 实际支付金额=("实际支付金额", "sum"), 商品件数=("商品数量", "sum"), ).reset_index()这样处理之后,后续计算用户复购、客单价都基于订单级数据,不会因为明细多行导致订单数虚高。GMV和净销额的口径分别计算:
gmv = valid["支付金额"].sum() # 全量支付金额 净销售额 = order_level["实际支付金额"].sum() # 扣除退款后的净额 退款率 = valid["退款金额"].fillna(0).sum() / valid["支付金额"].sum() 客单价 = 净销售额 / order_level["订单号"].nunique()这几个指标看着简单,但每次我都要把它们的定义再和业务方口头确认一次。尤其是退款率的“分母”,用支付金额还是实际收款金额,不同场景想表达的东西完全不同。
2.3 时间维度聚合的细节
指标算出总量只是第一步,判断走势必须按天、按周、按月聚合。这里最容易踩的坑是时间精度和时区。
我的处理习惯是把所有时间都先to_datetime,然后统一规范成字符串日期,再做分组聚合。
order_level["支付日期"] = pd.to_datetime(order_level["支付时间"]).dt.date daily = order_level.groupby("支付日期").agg( 订单量=("订单号", "nunique"), 净销售额=("实际支付金额", "sum"), 支付件数=("商品件数", "sum"), ).sort_index()如果要做周度或月度趋势,resample更优雅:
order_level = order_level.set_index(pd.to_datetime(order_level["支付时间"])) monthly = order_level.resample("ME").agg( 净销售额=("实际支付金额", "sum"), 订单量=("订单号", "nunique"), )这里用"ME"而不是"M",比较老的pandas版本会报警告。时间字段如果原始数据里带了时区(比如“2024-11-05T02:00:00+00:00”),先统一转换到本地时区再取日期,否则跨零点订单会被归到前一天,趋势图在某几天会肉眼可见地凹陷。
我在展示结果时不会只给一张表,还会加一行口径备注:“净销售额已扣除退款,按支付日期统计”。这个习惯能大幅减少后续沟通成本,也让报告显得专业。对于一个要反复出的销售周报,口径稳定比算得“好看”重要得多。
3. 用户和商品深度拆解:从整体大盘到业务可执行的结论
3.1 复购率、客单价、用户分层怎么算
整体数算完了,业务方往往还会追着问一句:“到底是老客在买还是新客在买?”于是要把用户视角拆出来。
先按用户ID聚合,我需要每个用户的订单数、消费总额、首次支付时间和最近支付时间。
user_orders = order_level.groupby("用户ID").agg( 订单数=("订单号", "nunique"), 总消费=("实际支付金额", "sum"), 首次支付时间=("支付时间", "min"), 最近支付时间=("支付时间", "max"), ).reset_index() user_orders["是否复购"] = (user_orders["订单数"] >= 2).astype(int) 复购率 = user_orders["是否复购"].mean()这里有个坑:复购率的分母到底是“购买过的用户数”还是“某个月购买过的用户数”。我用的是全周期口径,只适合静态复盘。如果业务方想看“本月新客下个月有多少回来买”,你得在前端定义回流周期。比如定义一个“90天内从未下单复购”用户,再逐月追踪转化。这样复杂度会上来不少,但更有参考价值。
如果客户量级较大,还可以做简版RFM分层。我习惯把用户简化为几类:
| 用户类型 | 判定逻辑 | 运营含义 |
|---|---|---|
| 高价值活跃 | 最近30天有购买且总消费排名前20% | 重点维护 |
| 待唤醒 | 最近90天内有购买,过去30天没有 | 定向触达 |
| 沉默用户 | 最近90天无购买 | 谨慎促活 |
| 新客 | 首次支付在近30天内 | 关注二次转化 |
这里的边界不是固定的,不同业务差异很大。但通过RFM简化分层的思路,可以帮业务方快速找到运营动作的抓手。
3.2 品类和商品的贡献拆解
商品维度的分析,用一个分组就能拿到核心结论。
prod = valid.groupby("商品ID").agg( 商品名称=("商品名称", "first"), 销量=("商品数量", "sum"), 销售额=("实际支付金额", "sum"), 订单数=("订单号", "nunique"), ).sort_values("销售额", ascending=False).reset_index() prod["销售额累计占比"] = prod["销售额"].cumsum() / prod["销售额"].sum()这个“累计占比”列特别有用。头部的款贡献巨大销售额,尾部长尾一堆却只撑起少量盘子。我在实际分析里发现,差不多前十款单品能贡献接近40%的销售额,后边几百款加起来才勉强追平。这个结论对业务的意义非常直接:选品和库存策略应该品类聚焦,而不是平均用力。
同时还要看退款率高的类目。把类目维度聚合后,看一眼“销量、销售额、退款率”三列并列的表格。哪一类卖得多但退款率明显高,往往意味着详情页描述、尺码或者质量有问题,这类商品就算销量好看,利润也可能被退款和售后吃掉。我那次分析里就有一个比较典型的类目,销量排前三,退款率接近整体均值两倍,后来业务方顺着这个线索去查了供应商和品控,这算是分析直接助推业务动作的好例子。
4. 用图表回答问题,而不是自嗨:实际画图的经验
4.1 趋势图和堆叠图:先讲销量变化
数据算完以后,一个很容易被忽略的环节是“图表服务于结论”。我见过很多人把几十个图表全扔在一张画布上,五颜六色,看到最后也不知道想表达什么。正确的做法是一张图回答一个具体问题。
比如业务方问“最近销量到底怎么回事”,我就先画一张近90天的净销售额趋势线图。这个图只回答一个事情——整体是涨是跌,哪个节点出现了明显波动。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(daily.index, daily["净销售额"], linewidth=1.5, label="净销售额") ax.set_title("近90天支付净销售额趋势") ax.set_xlabel("支付日期") ax.set_ylabel("净销售额(元)") ax.legend() plt.tight_layout() plt.show()中文字体的问题需要留意,Windows和Mac没有SimHei不一定会显示,改成"Arial Unicode MS"或系统里已有的中文字体即可。图表保存时可以plt.savefig("output/销售趋势.png", dpi=150, bbox_inches="tight"),但保存的目录要先建好,不然会报找不到路径。
如果还要看渠道结构变化,我倾向画堆积柱状图。每日总销售额的一柱,叠加各渠道的贡献,既能看出总量,也能看出结构变化。
4.2 结构图和条形图:再看品类和地域
讲品类结构时,水平条形图比垂直柱状图好读。类目名称常常长达六七个字,垂直柱状图把标签转90度,反而难看。直接用条形图展示销售额Top类目,再配上占比百分比,业务方一眼能抓到重点。
地域维度我用简单的TOP城市条形图就够了。这里不推荐一上来就去做地图,数据量不大时地图好看但信息提取效率低。做分析,尤其是快速复盘时,表和图都要以信息获取效率为先。
一个重要体会:真正的干货结论通常不会单独出现在图表里,而是落在“趋势图里的异常点”和“结构表里的占比差”上。所以我每次画完图,都会在旁边加文本说明,比如“11月20日销售额异常偏低,疑似数据缺失,待核”,让看图的人知道这个波动到底是业务问题还是数据问题,避免误读。
5. 这一轮分析里踩过的坑,按排查链路写成清单
5.1 测试订单与退款订单污染统计结果
这轮分析最曲折的一个坑发生在第一版汇总出来时。业务方看着销售额“两千三百万”嘀咕说太高了,我第一反应是口径没对齐,后来逐层排查,发现根本不是口径问题,是数据质量污染。
排查链路是这样的:
第一步,看整体趋势线,发现有一天的销售额异常高,比正常天数高出好几个量级。第二步,把那天的明细筛选出来,发现订单量只有不到一千笔,但单笔支付金额在五千到三万元之间。第三步,按订单号查这天的买家,结果都是几个内部测试账号。原来后台订单表里混入了测试单,特征是买家账号包含“test”“测试”,订单备注里也有一批“内部测试勿发”。第四步,建立过滤规则:账号包含测试关键词、支付金额小于等于0、订单备注含“测试”“开发”的,全部排出统计。
这个坑的教训是:永远不要全盘相信后台导出数据是“干净的”。电商数据里测试单、刷单单、异常单、员工内购单都有可能混入。建好数据质量白名单和黑名单,远比事后找原因更高效。我的做法是把过滤规则写成一个函数,每次导入都要执行:
def is_test_order(order_id: str, user_id: str, remark: str = "") -> bool: 测试标记 = ["test", "测试", "内部", "开发单"] if any(kw in str(order_id).lower() for kw in 测试标记): return True if any(kw in str(user_id).lower() for kw in 测试标记): return True if any(kw in str(remark) for kw in 测试标记): return True return False5.2 下单时间、支付时间、时区这些细节
另一个坑更隐蔽:每天的销售额在凌晨那两三个小时几乎为零,白天则异常平滑。业务方也发现了这个怪异现象,但解释不清。
我一开始怀疑是数据确实如此,但凌晨本来单量少,不至于完全为零。于是做了一个简单的分时聚合,结果发现凌晨0点到1点完全没有订单。排查链路:
第一步,查看原始时间字段的样例,发现它是带时区偏移的字符串,例如"2024-11-05T16:30:15+08:00"。第二步,直接pd.to_datetime后,部分数据保持了本地时间,部分在传给前端展示时被强转成了UTC。第三步,定位到我的处理脚本里对时间字段做了两次转换,一次在读取时区,一次在聚合时统一tz_convert,第二次用错了时区参数。第四步,统一约定:所有原始时间在导入后第一时间转成北京时间并去掉时区信息,后续任何环节都不再做第二次转换。
这段排查让我养成了一个习惯:在项目里写一行注释,标注“本项目所有时间为北京时间,数据源为东八区,禁止二次转换”。听上去有点小题大做,但在多人协作、多步骤计算的场景里,这条约定真的能省很多事。
5.3 数据质量自查清单
把这几轮踩坑经验抽象总结后,我每次跑正式结果前,都会对照一张检查清单。写下来供你参考:
| 检查项 | 检查方法 | 通过标准 |
|---|---|---|
| 行数合理性 | df.shape与业务预期对比 | 偏差不超过10% |
| 枚举值完整性 | value_counts查看状态字段 | 无意外状态 |
| 测试订单过滤 | 按账号、备注、金额规则过滤 | 测试单占比小于0.5% |
| 重复订单检查 | 对订单号去重后对比明细行数 | 多行子订单已合并 |
| 时间字段统一 | 抽查首尾日期和凌晨时段 | 无整段缺失,无跨天错位 |
| 金额字段口径 | 抽查几个订单手动加总 | 与代码计算结果一致 |
| 退款金额处理 | 检查退款金额是否有重复统计 | 退款金额小于等于支付金额合理值 |
这不是一个百试百灵的模板,但每次套用,我基本都能在交报告之前把低级错误消灭干净。
6. 把脚本沉淀成分析小工具,而不是一次性代码
6.1 用函数和配置文件管理重复劳动
第一次分析时,我的代码全是线性往下推进,一个几千行的notebook跑完就关。第二次再跑的时候,发现复制粘贴只能复制结构,记不住当初哪些地方埋了坑,于是我开始把分析脚本重构成一个小项目。
基本的目录结构是:
某电商销售分析/ ├── data/ # 原始数据放置目录 ├── output/ # 输出结果、图表、报告 ├── src/ │ ├── load_data.py # 数据读取与基础检查 │ ├── clean_data.py # 过滤测试单、统一时间、合并订单 │ ├── metrics.py # 核心指标计算 │ └── plot_charts.py # 绘图封装 └── config.yaml # 文件路径、口径参数再把经常变的参数抽到配置文件里,比如目录路径、要过滤的关键词、时间字段名、金额字段名。这样换一份数据、换一个平台,不用改逻辑,只需改配置。
# config.yaml data_path: "data/某电商订单数据.csv" output_dir: "output/" filter_keywords: ["test", "测试", "内部"] datetime_column: "支付时间" amount_column: "实际支付金额"加载数据时也简单封装,统一编码处理:
def load_table(path: str) -> pd.DataFrame: if path.endswith(".xlsx"): return pd.read_excel(path) return pd.read_csv(path, encoding="utf-8-sig")为什么要做这种看起来“多余”的工程化?因为分析工作的复利用率比想象中高。同样一份数据,本周看周报,下周看月报,月底还要复盘活动。没有模块化封装,每次都要把上一个notebook从头翻到尾找入口,太浪费时间。
6.2 一份简单的数据质量报告示例
最后我会在项目里加一个自动生成质量报告的小函数,把这次分析的基本信息和检查结果输出成Markdown文件,放到output目录里。它是给未来的自己看的,也是给业务方确认的底稿。
def write_quality_report(quality_dict, path): lines = ["# 数据质量报告", ""] for key, value in quality_dict.items(): lines.append(f"- {key}: {value}") with open(path, "w", encoding="utf-8") as f: f.write("\n".join(lines))实际操作下来,这个小文件帮我挡掉过很多次“你为什么这么算”的追问。对方一看到报告里的口径说明和过滤规则,通常就会补一句“行,没问题我们再聊下一步”。分析工作的价值在于让结论经得起追问,把过程变得透明、可检查,就是其中一个关键环节。
这轮电商销售数据分析还有一个收尾动作,我习惯把所有数字最终落成三个可直接执行的动作,比如“下个月重点跟进某类目退货率”“对高频低金额用户做一次定向复购活动”“暂停投放某两个产出比偏低的渠道”。只有落到这类能动起来的结论上,分析才算真正交圈。