pandas 打地基——loc / groupby / pivot / merge / datetime 一篇理解(以电商分析为例)
本文与《SQL 打地基——SELECT / WHERE / JOIN / GROUP BY / HAVING 一篇理解(以电商分析为例)》呼应,用最常用的 5 大核心能力 搭起 pandas 的“肌肉记忆”:loc/iloc(取数/筛选)、groupby(分组聚合)、pivot/pivot_table(透视)、merge(连接)、datetime(时间处理)。
准备:一份微型电商日志
import pandas as pd
# 构造一张“事件表”(一行=一个用户在某时刻做了一个动作)
raw = pd.DataFrame({
"user_id": ["u1","u1","u1","u2","u2","u3","u4"],
"event": ["view","cart","purchase","view","cart","view","view"],
"ts": pd.to_datetime([
"2025-09-01 10:00","2025-09-01 10:02","2025-09-01 10:05",
"2025-09-01 11:00","2025-09-01 11:03","2025-09-02 12:00","2025-09-02 15:00"
]),
"channel": ["ads","ads","ads","seo","seo","ads","ads"],
"price": [None,None,99,None,None,None,None],
})
表格呈现如图

其中event 有三种:view 浏览、cart 加购、purchase 购买(漏斗三层)。
一、loc / iloc —— 取数与筛选(类比 SQL: SELECT + WHERE)
loc用标签/列名取数;iloc 用位置取数。通俗来说:loc 是按“名字/条件”选,iloc 是按“位置/下标”选。“&”是“和”,raw.event=="view" 和raw.channel=="ads"两个条件要加小括号
df.loc[行条件, 列名列表]
df.iloc[行号/切片, 列号/切片]
(1)需求一:事件是 view 且渠道是 ads,只取出 user_id 和 ts 两列
效果如图

(2)需求二:取出表格的第2行,第2行,第4行;第0列,第2列
效果如图

代码如下:
# (1) 条件筛选 + 选列
# 业务筛选:事件是 view 且渠道是 ads,只拿 user_id 和 ts 两列
beijing_view = raw.loc[(raw.event=="view") & (raw.channel=="ads"), ["user_id","ts"]]
# (2) iloc:第 0,2,4 行;第 0,2 列
sample_pos = raw.iloc[[0,2,4], [0,2]]
二、groupby...agg...:“分组 + 聚合”
pandas 中的 groupby 就像 SQL 的 GROUP BY ;把数据按某列分组,再在每组上做统计(均值、中位数、计数、去重计数……)。相当于返回一个 GroupBy 对象,它“记住了”分组规则,不做计算。
agg 接受一个 字典:{列名: 聚合函数},对每个组里的每一列,执行你指定的函数,然后把结果合并到一张新表。它存在于多种对象上,例如:
| Series.agg(...) | 对一列做聚合 |
| DataFrame.agg(...) | 对多列做聚合(可 row-wise/column-wise) |
| GroupBy.agg(...) | 分组后对组内做聚合 |
| Resampler.agg(...) | 时间重采样后的聚合(按天/周等) |
| Rolling/Expanding.agg(...) | 滑窗/累积窗口的聚合 |
聚合后,agg可以接收很多函数:
| 函数 | 作用 | NA 处理 |
| sum | 求和 | 忽略 NA(相当于按有效值求和) |
| mean | 均值 | 忽略 NA |
| median | 中位数 | 忽略 NA |
| min/max | 最小/最大 | 忽略 NA |
| std/var | 标准差/方差(样本) | 忽略 NA |
| count | 非空计数 | 忽略 NA(只算非 NA) |
| size | 行数 | 包含 NA(不看列是否为空) |
| nunique | 去重后计数(默认不统计 NA) | 默认 不计 NA(可 dropna=False) |
| quantile | 分位数,需提供 q(0~1),如 0.95 |
需传参 |
(1)需求一:取出每个渠道的“去重用户数”(活跃用户口径)
这句话看着很绕,但是翻译成人话就是算一算:在广告渠道(ads)、自然搜索渠道(seo)等等,每个渠道里到底来了多少不同的用户。如果一个用户在同一个渠道出现很多次,也只算 1 个。这个数字一般就被叫作 活跃用户数。
效果如图
(2)需求二:购买的日级指标(购买用户数、订单数、GMV)
意思是把所有“购买行为”单独拎出来,按“每天”来统计 3 个数字:
-
购买用户数:当天到底有多少不同的用户下单。
-
订单数:当天一共发生了多少个下单事件。
-
GMV:当天成交的总金额(把当天所有订单价格加起来)。
效果如图

代码如下
# 每个渠道的“去重用户数”(活跃用户口径)
active_users = raw.groupby("channel")["user_id"].nunique() # nunique=去重计数
# 购买的日级指标(购买用户数、订单数、GMV)
p = raw[raw.event == "purchase"]
p_daily = p.groupby(p["ts"].dt.date).agg({
"user_id": "nunique", # buyers
"event": "size", # orders(用事件数近似)
"price": "sum", # GMV
}).rename(columns={"user_id":"buyers","event":"orders","price":"GMV"})
代码分析
第 1 部分:groupby("channel")["user_id"].nunique()
-
raw.groupby("channel")
→ 按照channel这一列的值分组(ads 一组,seo 一组……)。 -
["user_id"]
→ 在每组里只看user_id这一列。 -
.nunique()
→ 计算“去重后的数量”。
结果是一个 Series:
channel
ads 3
seo 1
Name: user_id, dtype: int64
表示:ads 渠道有 3 个活跃用户,seo 渠道有 1 个。
第 2 部分:p = raw[raw.event == "purchase"]
-
这是用
loc的简写:把event == "purchase"的行筛出来。 -
得到只包含购买行为的子表
p。
第 3 部分:p["ts"].dt.date
-
p["ts"]→ 时间戳这一列。 -
.dt→ pandas 的 datetime 访问器,可以取出时间的某个组成部分。
常见:.dt.year,.dt.month,.dt.date,.dt.hour。 -
.dt.date→ 提取“日期部分”(去掉时分秒,只保留年月日)。
第 4 部分:groupby(...).agg({...})
-
groupby(p["ts"].dt.date)→ 按日期分组。 -
.agg({...})→ 在每组里做多种聚合统计。字典里的 key=列名,value=聚合方法。
其中
{
"user_id": "nunique", # 每天有多少不同用户购买
"event": "size", # 每天有多少条购买事件(近似订单数)
"price": "sum", # 每天购买的总金额(GMV)
}
第 5 部分:.rename(columns={...})
聚合后的结果表列名还是原始列名(user_id, event, price),不直观。
用 rename 把它们改成更好理解的名字:
-
user_id→buyers(购买用户数) -
event→orders(订单数) -
price→GMV(总成交额)
类比SQL
SELECT DATE(ts) AS date,
COUNT(DISTINCT user_id) AS buyers,
COUNT(*) AS orders,
SUM(price) AS GMV
FROM raw
WHERE event = 'purchase'
GROUP BY DATE(ts);
三、pivot / pivot_table:把“长表 → 宽表”
pivot与pivot_table的区别
pivot(index, columns, values):要求 (index, columns) 组合唯一。
pivot_table(..., aggfunc=...):不唯一也能用,先聚合再透视;默认均值。
为什么要“宽表化”?
因为很多分析要 对比同一用户在不同事件上的关系,比如漏斗(view→cart→purchase 顺序)。
如果还是长表形式,你要对比同一个用户的三行,很麻烦;变成宽表后,同一行里就能直接对比。
需求:需要一个 “用户×步骤”宽表,用来做漏斗顺序校验。其中“行=用户、列=步骤、值=最早发生时间”
效果如图

代码如下
# 每个用户、每个事件的“最早时间”
first_time = raw.groupby(["user_id","event"], as_index=False)["ts"].min()
# 长表 → 宽表(行=user,列=event,值=ts)
wide = first_time.pivot(index="user_id", columns="event", values="ts")
# 按漏斗顺序重排列
wide = wide.reindex(columns=["view","cart","purchase"])
print(wide)
四、merge:连接两张表
就像 SQL 的 JOIN。常见 how:inner(内连接)、left(左连接)、right、outer(全外)。
内置参数
# 1) 同名键 inner join
out = pd.merge(A, B, how="inner", on="user_id")
# 2) 不同名键 left join
out = A.merge(B, how="left", left_on="uid", right_on="user_id")
# 3) 用索引对列 join
out = A.merge(B, left_index=True, right_on="user_id")
# 4) 防止列名冲突,用自定义后缀
out = A.merge(B, on="user_id", suffixes=("_left", "_right"))
# 5) 查看每行来自哪里(左/右/两边)
out = A.merge(B, on="user_id", how="outer", indicator=True)
# 6) 断言匹配关系,避免意外“一对多”膨胀
out = A.merge(B, on="user_id", how="left", validate="one_to_one")
需求:合并两个表
效果如图

代码如下
# 先取购买事件的“首单时间和金额”
first_order = (raw[raw.event=="purchase"]
.sort_values(["user_id","ts"]) # 按时间排序
.drop_duplicates(subset=["user_id"]) # 保留每人第1单
[["user_id","ts","price"]]
.rename(columns={"ts":"first_order_time","price":"first_order_price"}))
# 连接到用户画像上(保留所有用户)
profile = pd.merge(users, first_order, on="user_id", how="left")
print(profile)
五、datetime:日期相关统计
1. 转时间格式
在原始数据里,ts 往往是字符串。先统一转成 pandas 的时间戳类型(datetime64[ns]):
df["ts"] = pd.to_datetime(df["ts"], errors="coerce") # 非法时间转为 NaT(空值)
errors="coerce":遇到 0000-00-00、拼写错误等,把它们变 NaT,避免报错。
2. 拿取“年/月/日/小时/周”
df["date"] = df["ts"].dt.date # 只要“日期”(去掉时分秒)
df["hour"] = df["ts"].dt.hour # 小时(0~23)
df["ym"] = df["ts"].dt.to_period("M") # 年-月周期对象(如 2025-09)
df["dow"] = df["ts"].dt.dayofweek # 星期(周一=0)
df["ts_floor_h"] = df["ts"].dt.floor("H") # 向下取整到整点
df["ts_floor_d"] = df["ts"].dt.floor("D") # 向下取整到当天 00:00
# 还有 .ceil("H") 向上取整;.normalize() 直接把时间设为当天 00:00
3. 最常见的“按日/按小时统计”
3.1 按“日”统计 DAU(每天去重用户)
需求
每天有多少不同用户来过(活跃用户数,DAU)。
思路
-
把时间列
ts提取成“日期”这一粒度(去掉时分秒),这样同一天的事件能归到同一桶。 -
按
date分组。 -
每组统计 去重用户数(
nunique())。
df["date"] = df["ts"].dt.date
dau = df.groupby("date")["user_id"].nunique()
3.2 购买的“日级指标”(购买用户数、订单数、GMV)
需求
对“购买”这类事件,按天统计三件事:
-
当天有多少不同的购买用户(buyers)
-
当天发生了多少购买事件条数(orders,通常≈订单数)
-
当天的成交额(GMV)
思路
-
先把只有购买的记录筛出来(这样后面聚合不被其他事件干扰)。
-
派生
date(同上)。 -
确保金额是数值(字符串/货币符号要清洗)。
-
groupby(date)后做多指标聚合:-
user_id→nunique(购买用户数) -
event→size(行数 = 购买事件条数 ≈ 订单数) -
price→sum(总成交额)
-
p = df.loc[df["event"]=="purchase"].copy()
p["date"] = p["ts"].dt.date
p["price"] = pd.to_numeric(p["price"], errors="coerce")
daily = (p.groupby("date", as_index=False)
.agg(buyers=("user_id","nunique"), # 购买用户数
orders=("event","size"), # 订单条数(事件数近似)
GMV=("price","sum"))) # 成交额
3.3 按“小时”统计浏览量
需求
每个整点(如 10:00–10:59)发生了多少条浏览事件(view)。
思路
-
过滤出
event == "view"。 -
把
ts向下取整到小时(例如 10:23→10:00),这样同一小时的事件落在同一时间桶。 -
对这个“整点时间”分组,统计行数。
views_per_hour = (df.loc[df["event"]=="view"]
.groupby(df["ts"].dt.floor("H")) # 每小时一个桶
.size())
4.时间统计速查
| 需求 | 写法 |
| 字符串→时间戳 | pd.to_datetime(df["ts"], errors="coerce") |
| UTC→上海时区 | df["ts"]=pd.to_datetime(...,utc=True).dt.tz_convert("Asia/Shanghai") |
| 取日期 | df["date"]=df["ts"].dt.date |
| 取小时 | df["hour"]=df["ts"].dt.hour或df["ts"].dt.floor("H") |
| 每日去重用户 | df.groupby(df["ts"].dt.date)["user_id"].nunique() |
| 每小时浏览数 | df.loc[df.event=="view"].groupby(df["ts"].dt.floor("H")).size() |
| 规则时间序列按天 | df.set_index("ts").resample("D").agg({...})或resample("D", on="ts") |
| 补齐空日期 | resample("D").size().fillna(0)或reindex(full_range, fill_value=0) |
六、操作对照表
| 需求 | pandas 写法 | 备注 |
| 条件筛选 | df.loc[(cond1) & (cond2), col_list] | `&/ /~` 加括号 |
| 选位置 | df.iloc[row_slice, col_slice] | 右端不包含 |
| 赋值 | df.loc[cond, col] = val | 不要链式赋值 |
| 去重计数 | groupby(...)[col].nunique() | UV/DAU |
| 行数 | groupby(...).size() | 事件次数 |
| 透视 | pivot(index, columns, values) | 唯一组合 |
| 透视(聚合) | pivot_table(..., aggfunc='first') | 不唯一先聚合 |
| 连接 | merge(left,right,on=...,how=...) | join 类型 |
| 日期 | to_datetime + .dt.date/hour | 解析后再分组 |
七、完成“漏斗”和“日级指标”
7.1 用户×步骤的“最早时间”宽表
first_time = raw.groupby(["user_id","event"], as_index=False)["ts"].min()
wide = first_time.pivot(index="user_id", columns="event", values="ts").reindex(columns=["view","cart","purchase"])
7.2 顺序校验(purchase≥cart≥view )
import numpy as np
valid = np.ones(len(wide), dtype=bool)
for prev, nxt in zip(["view","cart"],["cart","purchase"]):
valid &= wide[nxt].isna() | (wide[prev].notna() & (wide[nxt] >= wide[prev]))
wide_valid = wide[valid]
7.3 漏斗人数与转化率
reach = wide_valid.notna().sum() # 每层到达的“去空”人数
base = reach.iloc[0] if len(reach)>0 else 0 # 第一层人数
funnel = pd.DataFrame({
"step": ["view","cart","purchase"],
"reach_users": reach.values,
"step_conv": (reach/ reach.shift(1)).fillna(1.0).values, # 相对上一层
"overall_conv": (reach/ base).fillna(0.0).values, # 相对第一层
})
print(funnel)
7.4 日级指标
raw["date"] = raw["ts"].dt.date
DAU = raw.groupby("date")["user_id"].nunique()
orders = raw[raw.event=="purchase"].groupby("date").size().rename("orders")
GMV = raw.loc[raw.event=="purchase","price"].groupby(raw["date"]).sum(min_count=1).rename("GMV")
更多推荐


所有评论(0)