1. 拿到一份用户行为日志,先别急着画图
很多人做用户行为分析,第一步就是打开 Jupyter Notebook,pd.read_csv一读,然后直接df.groupby('behavior_type').count().plot()出个柱状图,觉得分析就算做完了。我见过太多这样的项目,最后交出来的东西就是几张花里胡哨的饼图,问一句“数据里有多少异常”“时间字段是什么格式”“用户ID有没有重复”就答不上来。
数据处理这一步,在整个用户行为分析链路里占的比重,远比大多数人想象的要大。一份原始日志从落库到能拿去做漏斗、做留存、做路径分析,中间要经过字段清洗、类型转换、时间对齐、异常剔除、会话切分等一连串操作。这些活儿干得糙,后面所有分析结论都是沙上建塔。
这篇内容围绕“淘宝用户行为分析”的第一阶段——数据处理展开,用 Python 把一份典型的电商用户行为日志从原始状态处理成可分析的干净数据。适合正在做数据分析入门项目的人、需要处理埋点日志的后端或数据同学,以及想搞清楚“分析之前到底要做什么”的从业者。我会把每一步为什么这么做、参数怎么定、坑在哪里都讲清楚,代码可以直接拿去改字段名复用。
先明确一下这类日志的典型结构。电商用户行为日志通常包含这么几个核心字段:用户ID、商品ID、商品类目ID、行为类型、时间戳。行为类型一般用枚举值表示,比如 1 代表浏览、2 代表收藏、3 代表加购、4 代表购买。时间戳可能是 Unix 秒级或毫秒级,也可能是格式化字符串。字段名各家不一样,但语义大同小异。
注意:不同来源的日志字段命名差异很大,拿到数据第一件事永远是先看列名和几行样本,不要凭经验假设字段含义。
2. 数据加载阶段的类型陷阱与内存优化
2.1 为什么 read_csv 的默认类型会坑你
pd.read_csv在没有指定dtype的情况下,会自己推断每一列的类型。对于用户ID、商品ID这类看起来像数字的列,它会推断成int64。问题在于,电商平台的用户ID往往是十几位的长整型,甚至有些平台用字符串存储带前缀的ID。推断成int64本身不会丢精度(int64 能表示到约 9.2×10^18),但一旦某个ID超过这个范围,或者ID里混了非数字字符,整列就会变成object,后续做 merge 的时候类型不一致直接报错。
更隐蔽的坑是时间戳。如果时间戳是秒级的,比如 1541232000,read_csv会推断成int64,这没问题。但如果时间戳是毫秒级的,比如 1541232000000,同样是int64,你在转换的时候忘了除以1000,pd.to_datetime会给你一个公元五万多年的日期,而且不报错。我踩过这个坑,当时做时间序列聚合,发现所有数据都落在同一个“未来”的月份里,排查了半天才发现是单位问题。
所以加载阶段我习惯显式指定类型:
import pandas as pd import numpy as np dtype_dict = { 'user_id': 'int64', 'item_id': 'int64', 'category_id': 'int64', 'behavior_type': 'int8', 'timestamp': 'int64' } df = pd.read_csv('user_behavior.csv', dtype=dtype_dict)behavior_type用int8是因为它的取值范围很小(通常1到4),用int8比默认的int64省内存。别小看这一点,一份千万行的日志,光这一列就能省下几十MB。
2.2 分块读取与内存估算
千万行级别的日志,直接一把读进来,内存不够的机器会直接卡死。我的经验是,先估算一下数据规模。用wc -l看行数,用ls -lh看文件大小,然后按每行大约多少字节来推算。一般来说,一份包含5个字段、每行约50字节的CSV,一千万行大约是500MB的磁盘占用,读进内存后因为Python对象开销,可能膨胀到2到3GB。
如果内存吃紧,用chunksize分块处理:
chunk_list = [] for chunk in pd.read_csv('user_behavior.csv', dtype=dtype_dict, chunksize=500000): # 每块先做基础清洗 chunk = chunk.dropna(subset=['user_id', 'item_id', 'timestamp']) chunk_list.append(chunk) df = pd.concat(chunk_list, ignore_index=True)chunksize设多少合适?我的经验是50万到100万行一块。太小了循环次数多,IO开销大;太大了单块内存占用高,失去分块的意义。500000这个值在大多数场景下比较平衡。
提示:分块读取时,
pd.concat会额外复制一份数据,内存峰值大约是最终数据的两倍。如果内存实在紧张,可以考虑用dask或者先把每块处理完落盘再合并。
2.3 加载后立刻要做的三件事
数据读进来之后,别急着往下走,先做三件检查:
第一,看形状和内存占用。df.shape告诉你多少行多少列,df.memory_usage(deep=True)告诉你每列占多少内存。如果发现某列占用异常大,通常是object类型在作祟。
第二,看缺失值。df.isnull().sum()一目了然。用户行为日志里,用户ID和商品ID通常不会缺失,但类目ID有可能缺失,因为有些商品还没归类。
第三,看重复行。df.duplicated().sum()看有多少完全重复的行。埋点日志因为网络重传或者客户端重复上报,出现完全重复的行是很常见的。
print(df.shape) print(df.memory_usage(deep=True)) print(df.isnull().sum()) print(df.duplicated().sum())这三步花不了几秒钟,但能帮你提前发现大问题。我有一次拿到一份日志,duplicated().sum()出来占了总行数的15%,后来查出来是埋点SDK的一个bug导致重复上报。如果不检查,后面所有统计都会偏高。
3. 时间字段的拆解与行为序列的构建逻辑
3.1 时间戳转换的两种路径与选择依据
时间戳转日期时间,pd.to_datetime是最常用的。但这里有个关键选择:用unit='s'还是unit='ms'。前面说了,搞错单位会得到离谱的日期。我的做法是先取一个时间戳样本,看看它的数量级。10位数是秒级,13位数是毫秒级。
# 先看一个样本 sample_ts = df['timestamp'].iloc[0] print(len(str(sample_ts))) # 10 或 13 # 秒级 df['datetime'] = pd.to_datetime(df['timestamp'], unit='s') # 毫秒级 df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms')转换完之后,立刻检查一下日期的范围:
print(df['datetime'].min(), df['datetime'].max())如果最小值是1970年,说明有异常时间戳(比如0或者负数)。如果最大值是未来好几年,说明单位可能搞错了。正常的一份日志,时间范围应该和你拿到的数据时间段对得上。
3.2 从时间戳里能榨出多少信息
原始时间戳只有一个维度,但拆开之后能衍生出一堆有用的字段。我通常会拆出这些:
date:日期,用于按天聚合hour:小时,用于分析用户活跃时段weekday:星期几,用于分析工作日和周末的行为差异is_weekend:是否周末,布尔值,方便做分组
df['date'] = df['datetime'].dt.date df['hour'] = df['datetime'].dt.hour df['weekday'] = df['datetime'].dt.weekday # 0是周一,6是周日 df['is_weekend'] = df['weekday'].isin([5, 6])这些字段看起来简单,但在后续分析里价值很大。比如你发现购买行为集中在晚上8点到10点,那运营推送就应该卡在这个时间段。比如你发现周末的加购率明显高于工作日,那促销活动就可以考虑往周末倾斜。
3.3 行为序列的排序与会话切分
用户行为分析的核心是“序列”。同样一条购买记录,如果用户是先浏览了5个同类商品再下单,和直接搜索下单,背后的决策逻辑完全不同。所以数据处理阶段必须把行为按时间排好序。
df = df.sort_values(['user_id', 'datetime']).reset_index(drop=True)排序之后,可以给每个用户的行为编个序号:
df['behavior_seq'] = df.groupby('user_id').cumcount() + 1这个序号在后续做路径分析的时候很有用,比如“用户在第几次行为时完成购买”。
再进一步是会话切分。会话(session)的定义通常是:同一用户,相邻两次行为时间间隔超过某个阈值(比如30分钟),就算新的会话。这个阈值怎么定?电商场景下,30分钟是业界比较常用的经验值。因为用户浏览商品时,如果超过30分钟没有新动作,大概率是离开了,后面再来的行为应该算新的访问。
df['time_diff'] = df.groupby('user_id')['datetime'].diff().dt.total_seconds() df['new_session'] = (df['time_diff'] > 1800) | (df['time_diff'].isna()) df['session_id'] = df.groupby('user_id')['new_session'].cumsum()这段代码的逻辑是:先算同一用户相邻两条记录的时间差,如果超过1800秒(30分钟)或者是该用户的第一条记录,就标记为新会话的开始,然后累加得到会话编号。这样每个用户就有了user_id + session_id的唯一会话标识。
注意:会话切分的阈值没有绝对标准。内容社区可能用15分钟,长视频平台可能用60分钟。要根据业务场景来定,不要照搬。
4. 异常值识别与清洗的实操判断标准
4.1 哪些行必须删,哪些行可以留
数据清洗最怕两种极端:一种是删得太狠,把有效数据也删了;另一种是太保守,脏数据留着污染结果。我的原则是:影响核心字段完整性的行必须删,影响统计分布但字段完整的行先标记再决定。
必须删的情况:
user_id为空:没有用户标识,这条记录没法归属到任何用户,留着只会干扰用户维度的统计。item_id为空:同理,没有商品标识,商品维度的分析做不了。timestamp为空或为0:没有时间信息,序列分析无从谈起。- 完全重复的行:重复计数会直接导致指标虚高。
df = df.dropna(subset=['user_id', 'item_id', 'timestamp']) df = df[df['timestamp'] > 0] df = df.drop_duplicates()可以留但需要标记的情况:
category_id为空:商品类目缺失不影响用户行为序列,但在类目分析时要排除这些行。- 时间戳异常(比如超出数据采集周期):先标记,看看占比多少,再决定删不删。
4.2 用分位数法识别行为频次异常
有些用户的行为频次高得离谱,一天浏览几千个商品。这可能是爬虫,也可能是埋点bug。怎么判断?用分位数。
user_behavior_count = df.groupby('user_id').size() print(user_behavior_count.describe()) print(user_behavior_count.quantile([0.5, 0.9, 0.99, 0.999]))如果99.9%的用户行为次数在500以内,而最大值是50000,那这个最大值大概率是异常。但要注意,不能一刀切说超过某个值就删。电商大促期间,重度用户的浏览行为确实会暴增。我的做法是结合时间维度看:如果某个用户的行为集中在极短的时间内(比如1分钟内几百次),那基本可以判定为异常。
# 计算每个用户的行为时间跨度 user_time_span = df.groupby('user_id')['datetime'].agg(['min', 'max']) user_time_span['span_minutes'] = (user_time_span['max'] - user_time_span['min']).dt.total_seconds() / 60 user_time_span['behavior_count'] = df.groupby('user_id').size() user_time_span['freq_per_minute'] = user_time_span['behavior_count'] / (user_time_span['span_minutes'] + 1) # 每分钟行为超过10次的标记为可疑 suspicious_users = user_time_span[user_time_span['freq_per_minute'] > 10].index print(f"可疑用户数: {len(suspicious_users)}")这个freq_per_minute的阈值设多少,要看业务。正常用户浏览商品,一分钟看10个已经很快了。如果一分钟几百个,那肯定不是人在操作。
4.3 行为类型字段的校验
行为类型通常是枚举值,但日志里偶尔会出现意料之外的值。比如定义的是1到4,结果出现了0或者5。这种情况要先统计一下各值的分布:
print(df['behavior_type'].value_counts())如果出现了定义之外的值,有两种处理方式:如果数量极少(比如几十条),直接删掉;如果数量不少,那就要去查埋点文档,看看是不是新增了行为类型,或者是不是数据采集出了问题。
我遇到过一种情况:behavior_type里出现了'1'这样的字符串,和数字1混在一起。这是因为某些行的该字段被引号包裹了,read_csv推断成了object类型。解决办法是在加载时就指定dtype,或者在清洗时统一转换:
df['behavior_type'] = pd.to_numeric(df['behavior_type'], errors='coerce') df = df.dropna(subset=['behavior_type']) df['behavior_type'] = df['behavior_type'].astype('int8')errors='coerce'的作用是,遇到无法转换的值就变成NaN,然后统一删掉。这比直接报错中断要好,至少能知道有多少条转换失败。
5. 把清洗后的数据落成可复用的分析底表
5.1 为什么建议存成 Parquet 而不是 CSV
清洗完的数据,如果每次分析都重新跑一遍清洗流程,浪费时间不说,还容易因为代码改动导致结果不一致。所以清洗完应该落盘,存成一份“分析底表”。
存什么格式?CSV 是最通用的,但我不推荐。CSV 不保留数据类型,下次读进来又要重新推断类型,而且文件体积大。Parquet 是列式存储,保留 schema,压缩率高,读取速度快。同样一份数据,Parquet 可能只有 CSV 的三分之一大小,读取速度快好几倍。
df.to_parquet('user_behavior_cleaned.parquet', index=False)如果环境里没有pyarrow或fastparquet,先装一个:
pip install pyarrow5.2 底表应该包含哪些字段
清洗后的底表,我建议包含这些字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| user_id | int64 | 用户标识 |
| item_id | int64 | 商品标识 |
| category_id | int64 | 类目标识,可能为空 |
| behavior_type | int8 | 行为类型枚举 |
| datetime | datetime64 | 完整时间 |
| date | date | 日期 |
| hour | int8 | 小时 |
| weekday | int8 | 星期几 |
| is_weekend | bool | 是否周末 |
| session_id | int32 | 会话编号 |
| behavior_seq | int32 | 用户行为序号 |
这些字段覆盖了后续做漏斗分析、留存分析、路径分析、时段分析的基本需求。不用把所有衍生字段都塞进去,够用就行,底表太宽反而影响读取效率。
5.3 落盘前的最终校验
存之前,跑一遍最终校验,确保底表是干净的:
# 核心字段无缺失 assert df[['user_id', 'item_id', 'behavior_type', 'datetime']].isnull().sum().sum() == 0 # 行为类型在预期范围内 assert df['behavior_type'].isin([1, 2, 3, 4]).all() # 时间范围合理 assert df['datetime'].min().year >= 2020 assert df['datetime'].max() <= pd.Timestamp.now() # 无完全重复行 assert df.duplicated().sum() == 0 print("校验通过,底表行数:", len(df)) print("独立用户数:", df['user_id'].nunique()) print("独立商品数:", df['item_id'].nunique())这几个断言看起来简单,但能挡住绝大多数低级错误。我习惯把这段校验代码单独存成一个函数,每次清洗完都跑一遍,心里踏实。
提示:
assert在生产环境可能被优化掉(Python 的-O参数会跳过 assert),如果是在正式的数据管道里,建议用if not condition: raise ValueError(...)来代替。
6. 几个我踩过的坑和对应的处理技巧
6.1 时区问题:你以为的0点不是0点
如果日志里的时间戳是UTC时间,而你直接转成datetime不做时区转换,那分析“用户活跃时段”的时候就会整体偏移8小时。我做过一个项目,发现用户活跃高峰在凌晨4点,当时就觉得不对劲,后来一查,果然是UTC时间没转。
处理方式有两种:如果日志里有时区字段,按字段转换;如果没有,根据数据来源判断。国内电商平台的日志,如果时间戳是秒级Unix时间,通常是UTC,需要加8小时:
df['datetime'] = pd.to_datetime(df['timestamp'], unit='s') + pd.Timedelta(hours=8)但要注意,不是所有平台都用UTC。有些平台的埋点直接上报本地时间戳。所以最稳妥的办法是拿一条已知时间的行为记录,和实际业务时间对一下,确认时区。
6.2 大文件读取时的编码问题
CSV 文件的编码是个老生常谈的问题。utf-8是最常见的,但有些系统导出的文件是gbk或者utf-8-sig(带BOM)。用错了编码,轻则中文列名变乱码,重则直接报UnicodeDecodeError。
我的做法是先探测编码:
import chardet with open('user_behavior.csv', 'rb') as f: raw = f.read(100000) encoding = chardet.detect(raw)['encoding'] print(encoding)然后读取时指定:
df = pd.read_csv('user_behavior.csv', encoding=encoding)chardet不是百分百准确,但对于常见编码的探测准确率很高。如果不想装额外库,也可以按utf-8、gbk、utf-8-sig的顺序试,哪个不报错用哪个。
6.3 groupby 之后的索引重置
groupby之后如果不reset_index,得到的 DataFrame 索引是分组键,后续做 merge 或者 concat 的时候很容易出问题。我习惯在每次groupby聚合之后立刻reset_index(),保持索引是默认的整数索引。
# 不推荐 user_stats = df.groupby('user_id').agg({'behavior_type': 'count'}) # 推荐 user_stats = df.groupby('user_id').agg({'behavior_type': 'count'}).reset_index() user_stats.columns = ['user_id', 'behavior_count']另外,groupby之后如果要做多列聚合,用agg传字典的方式比多次groupby效率高。比如同时算行为次数和独立商品数:
user_stats = df.groupby('user_id').agg( behavior_count=('behavior_type', 'count'), unique_items=('item_id', 'nunique'), unique_categories=('category_id', 'nunique') ).reset_index()这种命名聚合的写法(pandas 0.25+ 支持)比先 groupby 再 rename 列要清晰得多。
6.4 内存不够时的降级策略
如果数据量实在太大,单机内存扛不住,有几个降级策略:
第一,只加载需要的列。pd.read_csv的usecols参数可以指定只读某几列,能省不少内存。
第二,用更小的数据类型。int64降成int32,float64降成float32,前提是取值范围允许。
第三,分块处理,每块处理完直接聚合,不保留原始明细。比如你只需要用户行为次数,那就不需要把明细全读进来,分块读、分块算、最后合并聚合结果就行。
from collections import defaultdict user_behavior_count = defaultdict(int) for chunk in pd.read_csv('user_behavior.csv', usecols=['user_id'], chunksize=500000): for uid, cnt in chunk['user_id'].value_counts().items(): user_behavior_count[uid] += cnt这种方式的代价是丢失了明细,后续如果想做别的分析得重新跑。所以适合目标明确的场景。
数据处理这件事,做多了就会发现,大部分时间不是在写代码,而是在做判断:这个值要不要删、这个阈值合不合理、这个字段能不能用。代码只是把判断落地的手段。上面这些操作和参数,是我在多个项目里反复调整后觉得比较稳的配置,但具体到你的数据,还是要先看样本、先做检查,再决定怎么处理。