在实际数据分析工作中,数据清洗(Data Cleaning) 往往是最耗时、却也是最关键的一步。原始数据几乎从来都不是“干净”的:缺失值、重复上报、类型混乱、文本不统一、极端异常……这些问题如果不解决,会直接导致后续分析的结论偏差甚至完全错误。尤其在电商领域,日志数据每天成百万上千万条,更需要一套高效、可复用、可追溯的清洗流程。
本教程以电商行为日志为例,结合 Pandas 的常用功能,从缺失值、重复值、类型校正、文本清洗到异常值处理,提供一份系统化的清洗思路与代码示例。
一、示例数据与准备工作
电商行为日志(事件表)常见字段:
| 字段名 | 实例 | 含义 |
| event_id | 事件唯一ID | |
| user_id | 用户ID | |
| session_id | 会话ID | |
| event | 事件类型 | |
| ts | 事件时间(UTC或本地) | |
| product_id | 商品ID | |
| price | 单价 | |
| qty |
数量 | |
| device |
设备/端 | |
| city | 城市 | |
| channel | 渠道来源 |
1. 载入数据
import pandas as pd
from pathlib import Path
# 建议优先使用 Parquet(快且类型安全),这里以 CSV 为例
df = pd.read_csv(
Path("./data/events.csv"),
dtype={
"event_id": "string",
"user_id": "string",
"session_id": "string",
"event": "string",
"product_id": "string",
"device": "string",
"city": "string",
"channel": "string",
},
parse_dates=["ts"], # 初步解析时间
keep_default_na=True, # 将常见空值标记为 NaN
na_values=["", "NA", "N/A", "null", "None"]
)
print(df.shape)
print(df.head())
print(df.info())
2. 基础检查清单
检查内容:
-
字段是否齐全?是否需要新增派生字段(如
date = ts.dt.date) -
缺失比例?极端异常值?
-
是否存在时间倒序或未来时间?
-
是否存在不在字典范围内的
event/device/channel?
# 缺失比例
missing_ratio = df.isna().mean().sort_values(ascending=False)
print(missing_ratio)
# 基本统计(数值列)
print(df[["price", "qty"]].describe(percentiles=[.01,.05,.95,.99]))
# 类别列取值检查
for col in ["event","device","channel"]:
print(col, df[col].dropna().unique()[:20])
二、缺失值处理
策略: 删除、填充、插值、业务合理改写(如无
product_id的view→ 可以保留;但purchase无price/qty→ 需要处理或剔除)。
1. 识别与分层统计
# 总体
na_overview = df.isna().sum().to_frame("n_na").assign(ratio=lambda x: x.n_na/len(df))
# 按事件类型分层
na_by_event = df.groupby("event").apply(lambda g: g.isna().mean()).sort_values("price", ascending=False)
2. 有条件填充/删除
-
规则 1:
purchase/cart事件必须有price与qty,否则删除或回补。 -
规则 2:
view事件product_id缺失可以保留(如浏览列表页),但要标记来源。 -
规则 3:
ts缺失:尽量从日志文件名/上报时间推断;无法推断则删除。
# 针对 price/qty 的条件修复示例
mask_need_price = df["event"].isin(["purchase","cart"]) & df["price"].isna()
mask_need_qty = df["event"].isin(["purchase","cart"]) & df["qty"].isna()
# 这里选择剔除(也可业务回填,如从订单明细回写)
df = df.loc[~(mask_need_price | mask_need_qty)].copy()
# 对 view 的 product_id 缺失做标记
df["product_missing"] = df["event"].eq("view") & df["product_id"].isna()
3. 数值列常见填充
-
qty缺失 → 视业务:购物车可能默认 1;谨慎处理。 -
price缺失 → 不建议盲填 0;应回溯订单表或剔除。
# 若确定业务默认 qty=1 且仅限 cart 事件:
df.loc[df["event"].eq("cart") & df["qty"].isna(), "qty"] = 1
建议: 缺失处理要记录日志与影响面,避免静默篡改数据分布。
三、重复值处理
电商日志经常出现重复上报(网络重试、埋点多发),常见去重维度:
1) event_id 唯一;
2)业务唯一键(user_id + ts ± window + event + product_id)。
1. 快速去重
# 1) 若有事件唯一 ID
df = df.drop_duplicates(subset=["event_id"], keep="first")
# 2) 业务键去重(无 event_id 时)
biz_key = ["user_id","event","product_id","ts"]
df = df.sort_values("ts").drop_duplicates(subset=biz_key, keep="first")
2. 时间窗口去重(同一秒多次上报)
# 将 ts 取到秒级,视业务而定
sec = df["ts"].dt.floor("s")
df["ts_sec"] = sec
# 同一用户、同一商品、同一事件、同一秒内保留首次
df = df.sort_values("ts").drop_duplicates(subset=["user_id","event","product_id","ts_sec"], keep="first")
注意: 去重前后请对关键指标(PV、UV、下单量、GMV)做对比,验证影响是否合理。
四、类型校正
常见问题:价格读成字符串、qty 混有非数字、时间时区混乱、类别列基数过高。
1. 数值列规范
# 强制转为数值,不可解析的设为 NaN
for col in ["price","qty"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
# 负数/零值检查(purchase 不应为负或 0)
mask_bad_price = df["event"].eq("purchase") & (df["price"] <= 0)
mask_bad_qty = df["event"].eq("purchase") & (df["qty"] <= 0)
df = df.loc[~(mask_bad_price | mask_bad_qty)].copy()
2. 时间与时区
# 若上一步未 parse 成 datetime:
df["ts"] = pd.to_datetime(df["ts"], errors="coerce", utc=True) # 统一为 UTC
# 需要本地时区再转换(例如 Asia/Shanghai)
df["ts_local"] = df["ts"].dt.tz_convert("Asia/Shanghai")
# 派生日期字段
df["date"] = df["ts_local"].dt.date
3. 类别列(低内存 & 值域约束)
cat_cols = ["event","device","channel","city","product_id","user_id","session_id"]
for c in cat_cols:
df[c] = df[c].astype("string") # 先规范为 string
# 若值域固定(如事件类型),可降为 Categorical 且限定取值
event_levels = ["view","cart","purchase","refund"]
df["event"] = pd.Categorical(df["event"], categories=event_levels)
五、文本清洗
目标:统一大小写、前后空白、异常字符、枚举映射。
# 统一小写 + 去空白
text_cols = ["device","channel","city"]
for c in text_cols:
df[c] = df[c].str.strip().str.lower()
# 异常字符清理(示例:仅保留字母、数字、下划线与连字符)
import re
norm = lambda s: re.sub(r"[^0-9a-zA-Z_\-]", "_", s) if pd.notna(s) else s
df["product_id"] = df["product_id"].astype("string").map(norm)
# 规则映射(同义归一)
channel_map = {
"sem":"sem", "adwords":"sem", "google_ads":"sem",
"organic":"organic", "seo":"organic",
"push":"push", "notification":"push"
}
df["channel"] = df["channel"].map(lambda x: channel_map.get(x, x))
Tip: 对高基数字段(城市、商品)可做标准化字典;对脏数据保留原值列与清洗后列双版本,便于追溯。
六、异常值/离群值
目的: 发现不合理的价格、数量、时间与行为序列。
1. 统计法
import numpy as np
# IQR 法:price、qty
q1, q3 = df["price"].quantile(0.25), df["price"].quantile(0.75)
iqr = q3 - q1
lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr
mask_price_outlier = (df["price"] < lo) | (df["price"] > hi)
# 处理策略:标记 or 截断 or 剔除(按业务决定)
df["price_outlier"] = mask_price_outlier
# Z-Score 示例(对 qty)
mu, sigma = df["qty"].mean(), df["qty"].std(ddof=0)
df["qty_z"] = (df["qty"] - mu) / (sigma if sigma else 1)
df["qty_outlier"] = df["qty_z"].abs() > 3
2. 业务规则异常
-
purchase的qty不应超过合理上限(如 100) -
price理应在某 SPU 的价格带内(可联表商品维度) -
refund不应出现在view之前(时序)
# 简单上限
df["qty_too_large"] = df["qty"] > 100
# 时序:同一用户/会话,事件顺序应符合 view → cart → purchase
by_user = df.sort_values("ts").groupby(["user_id","session_id"])
# 标记 purchase 是否缺少前置行为
prev_events = by_user["event"].shift(1)
df["prev_event"] = prev_events.astype("string")
df["purchase_without_cart"] = df["event"].eq("purchase") & ~df["prev_event"].isin(["cart","purchase"])
Tip:异常不等于错误。常将异常标记并在分析时排除或分层展示,而不是一律删除。
七、一致性校验与业务规则
将“数据质量”变成可执行检查。
checks = {
"ts_not_null": df["ts"].notna().all(),
"price_positive_on_purchase": (~(df["event"].eq("purchase") & (df["price"] <= 0))).all(),
"qty_positive_on_purchase": (~(df["event"].eq("purchase") & (df["qty"] <= 0))).all(),
"known_event_values": df["event"].isin(["view","cart","purchase","refund"]).all(),
}
quality = pd.Series(checks)
print(quality)
# 若需严格:
assert quality.all(), "数据质量检查不通过,请查看 quality 详情"
常见业务校验:
-
唯一性:
event_id是否唯一;或业务键是否唯一(允许窗口内合并)。 -
时序:同用户
ts单调不减;purchase不应早于相应view。 -
取值域:事件枚举、渠道枚举是否超范围。
-
关联性:
purchase的price*qty与订单表合计一致(如有)。
八、将清洗流程函数化
把步骤汇总为一个可配置的函数,便于在 脚本/生产任务中复用。
import re
EVENT_LEVELS = ["view","cart","purchase","refund"]
CHANNEL_MAP = {
"sem":"sem", "adwords":"sem", "google_ads":"sem",
"organic":"organic", "seo":"organic", "push":"push", "notification":"push"
}
def clean_events(raw: pd.DataFrame, *, tz: str = "Asia/Shanghai") -> Dict[str, Any]:
df = raw.copy()
# --- 缺失规范 ---
need_price_qty = df["event"].isin(["purchase","cart"])
df = df.loc[~(need_price_qty & (df["price"].isna() | df["qty"].isna()))].copy()
df["product_missing"] = df["event"].eq("view") & df["product_id"].isna()
# --- 去重 ---
if "event_id" in df.columns:
df = df.drop_duplicates(subset=["event_id"], keep="first")
df = df.sort_values("ts").drop_duplicates(
subset=["user_id","event","product_id","ts"], keep="first")
# --- 类型与时间 ---
for col in ["price","qty"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
df = df.loc[~(df["event"].eq("purchase") & ((df["price"] <= 0) | (df["qty"] <= 0)))].copy()
df["ts"] = pd.to_datetime(df["ts"], errors="coerce", utc=True)
df = df.loc[df["ts"].notna()].copy()
df["ts_local"] = df["ts"].dt.tz_convert(tz)
df["date"] = df["ts_local"].dt.date
# --- 文本清洗 ---
for c in ["device","channel","city","user_id","session_id","product_id"]:
if c in df.columns:
df[c] = df[c].astype("string").str.strip().str.lower()
df["channel"] = df["channel"].map(lambda x: CHANNEL_MAP.get(x, x))
df["event"] = pd.Categorical(df["event"], categories=EVENT_LEVELS)
# --- 异常标记 ---
q1, q3 = df["price"].quantile(0.25), df["price"].quantile(0.75)
iqr = q3 - q1
lo, hi = q1 - 1.5*iqr, q3 + 1.5*iqr
df["price_outlier"] = (df["price"] < lo) | (df["price"] > hi)
mu, sigma = df["qty"].mean(), df["qty"].std(ddof=0)
df["qty_z"] = (df["qty"] - mu) / (sigma if sigma else 1)
df["qty_outlier"] = df["qty_z"].abs() > 3
# --- 质量检查 ---
checks = {
"ts_not_null": df["ts"].notna().all(),
"known_event_values": df["event"].isin(EVENT_LEVELS).all(),
"positive_price_qty_on_purchase": (~(df["event"].eq("purchase") & ((df["price"] <= 0) | (df["qty"] <= 0)))).all(),
"non_negative_qty": (df["qty"] >= 0).all(),
}
report = {
"row_count": len(df),
"n_missing": df.isna().sum().to_dict(),
"checks": checks,
"outlier_ratio": {
"price": float(df["price_outlier"].mean()),
"qty": float(df["qty_outlier"].mean()),
}
}
return {"data": df, "report": report}
用法:
res = clean_events(df)
clean_df = res["data"]
quality_report = res["report"]
print(quality_report)

被折叠的 条评论
为什么被折叠?



