📖 本篇简介:本篇是 pandas 面试题精讲的进阶篇,专攻最能拉开差距的六大考点:groupby 聚合三兄弟(agg/transform/filter)、merge 的四种 join 方式、pivot_table 与 crosstab 的区别、时间序列的重采样/rolling/shift、向量化性能优化,以及 MultiIndex 高级操作。每道题按"题目 → 考点分析 → 参考答案代码(带注释)→ 追问与延伸"组织,文末配速查表与易错点。前置知识:已读完第 13 章分组聚合、第 11 章数据合并、第 14 章时间序列、第 17 章 MultiIndex。学完后你能从容应对中高级 pandas 面试。
基础篇解决的是"写得对",本篇解决的是"写得好、写得快、写得专业"。这些考点(groupby 三兄弟、merge join、透视表、时间序列)在真实工作中天天用,在面试中也是区分"会用 pandas"和"精通 pandas"的分水岭。
目录
1. groupby 三兄弟:agg / transform / filter
2. merge 的四种 join 方式
3. pivot_table 与 crosstab
4. 时间序列:重采样 / rolling / shift
5. 性能优化与向量化
6. MultiIndex 高级操作
7. 进阶考点速查表
8. 易错点总结与自测练习
1. groupby 三兄弟:agg / transform / filter
题目 1.1:groupby.agg、groupby.transform、groupby.filter 有什么区别?
考点分析:这是 groupby 面试的终极考题。一句话:
- agg(聚合):每组出一个标量结果,行数变少(压缩)。
- transform(变换):每组出和原行一样多的结果,行数不变(广播回去)。
- filter(过滤):整组留下或整组删掉,用于"按组条件筛选行"。
df = pd.DataFrame({
"部门": ["销售", "销售", "销售", "研发", "研发", "市场"],
"姓名": ["张三", "李四", "王五", "赵六", "钱七", "孙八"],
"工资": [12000, 15000, 10000, 20000, 22000, 13000],
})
# ---------- agg:每组压缩成一行 ----------
# 求每个部门的工资总和、平均、人数
print(df.groupby("部门")["工资"].agg(["sum", "mean", "count"]))
# sum mean count
# 部门
# 市场 13000 13000.0 1
# 研发 42000 21000.0 2
# 销售 37000 12333.33 3
# 多列 + 不同聚合:用字典指定
print(df.groupby("部门").agg(工资总额=("工资", "sum"), 平均工资=("工资", "mean")))
# ---------- transform:每组计算后"广播"回每一行 ----------
# 典型场景:算"每个人工资占部门总额的比例"
df["部门工资总额"] = df.groupby("部门")["工资"].transform("sum")
df["工资占比"] = df["工资"] / df["部门工资总额"]
print(df)
# 注意:transform 的结果和原 df 一样长,所以能直接 assign 成新列!
# ---------- filter:整组筛选 ----------
# 只要"人数 >= 2"的部门(市场部只有 1 人,整组被删掉)
print(df.groupby("部门").filter(lambda g: len(g) >= 2))
追问与延伸:
- 为什么"占比"必须用 transform 而不是 agg?——agg 结果只有 3 行(部门数),除不回去原表 6 行;transform 会把部门均值"复制"到该部门每一行,才能逐行相除。
- agg 和
df.groupby(...).mean()什么关系?——.mean()是 agg 的简写;.agg(["mean","sum"])是同时算多个。 - filter 和
df[df.groupby(...)["列"].transform(...) > v]哪个好?——filter 更省内存(直接删行);transform+mask 更灵活(可以保留其他列做二次筛选)。
题目 1.2:groupby.apply 和 transform 的区别?
考点分析:apply 最灵活——可以返回任意形状;transform 要求"返回的形状和输入组一致",所以更快、更安全。
df = pd.DataFrame({
"部门": ["销售", "销售", "研发", "研发"],
"工资": [12000, 15000, 20000, 22000],
})
# transform:必须返回与组等长的结果
df["部门内标准化"] = df.groupby("部门")["工资"].transform(
lambda x: (x - x.mean()) / x.std()
)
# apply:想返回啥返回啥(比如每组取 top1)
top = df.groupby("部门").apply(lambda g: g.nlargest(1, "工资"))
print(top)
追问与延伸:
- 性能?——transform 比 apply 快很多(pandas 知道输出形状,可以内部优化)。
- 常见坑?——apply 在新版 pandas 里会提示
DataFrameGroupBy.apply operated on the grouping columns,建议显式include_groups=False或先drop分组列。
2. merge 的四种 join 方式
题目 2.1:pd.merge 的 how 参数有几种?分别什么含义?
考点分析:和 SQL 一一对应,面试必问。四种:inner(内连接)、left(左外连接)、right(右外连接)、outer(全外连接)。
employees = pd.DataFrame({
"员工ID": [1, 2, 3, 4],
"姓名": ["张三", "李四", "王五", "赵六"],
"部门ID": [10, 20, 10, 30], # 部门ID=30 在部门表里不存在
})
departments = pd.DataFrame({
"部门ID": [10, 20, 40],
"部门名": ["销售", "研发", "财务"], # 部门ID=40 没有员工
})
# ---------- inner:只保留两边都匹配的行 ----------
print(pd.merge(employees, departments, on="部门ID", how="inner"))
# 张三/李四/王五 出现;赵六(部门30)、财务(部门40) 都丢了
# ---------- left:保留左表全部,右表匹配不上补 NaN ----------
print(pd.merge(employees, departments, on="部门ID", how="left"))
# 4 个员工都在;赵六那行 部门名 是 NaN
# ---------- right:保留右表全部 ----------
print(pd.merge(employees, departments, on="部门ID", how="right"))
# 3 个部门都在;财务那行 员工ID/姓名 是 NaN
# ---------- outer:两边都保留,匹配不上的补 NaN ----------
print(pd.merge(employees, departments, on="部门ID", how="outer"))
# 赵六(部门30) 和 财务(部门40) 都在,各自缺失的列补 NaN
# ---------- 进阶:键名不一样时用 left_on / right_on ----------
employees2 = employees.rename(columns={"部门ID": "dept_id"})
print(pd.merge(employees2, departments, left_on="dept_id", right_on="部门ID", how="left"))
# ---------- 索引做键 ----------
employees3 = employees.set_index("部门ID")
print(pd.merge(employees3, departments, left_index=True, right_on="部门ID", how="left"))
# ---------- 多列做键 ----------
# pd.merge(a, b, on=["城市", "月份"], how="inner")
追问与延伸:
pd.concat和pd.merge区别?——concat 是"堆叠"(上下或左右拼,不按键匹配);merge 是"按列匹配合并",类似 SQL JOIN。df1.join(df2)和 merge 区别?——join 默认用索引,是 merge 的简写。- 重复键会怎样?——两边都有重复键时,结果是笛卡尔积(行数 = 左重复数 × 右重复数),面试常考这个坑。
题目 2.2:merge 后为什么行数变多了?怎么排查?
考点分析:这是真实工作中最常踩的坑。
# 场景:左表 100 行,右表 200 行,merge 完变成 500 行——为什么?
# 答案:键不是唯一的,发生了"一对多"甚至"多对多"笛卡尔积。
# 排查三板斧:
# 1. merge 前先看键是否唯一
print(employees["部门ID"].is_unique) # False 说明左表键重复
print(departments["部门ID"].is_unique) # 右表键重复更致命
# 2. 用 validate 参数让 pandas 直接报错
pd.merge(employees, departments, on="部门ID", how="left", validate="many_to_one")
# validate: 'one_to_one' / 'one_to_many' / 'many_to_one' / 'many_to_many'
# 实际和预期不符时立刻报错,不会悄悄出笛卡尔积
# 3. 先去重再 merge
departments_dedup = departments.drop_duplicates(subset=["部门ID"])
追问与延伸:
indicator=True是干嘛的?——结果里多一列_merge,标记每行来自left_only/right_only/both,排查 join 问题神器。- 想把右表的列"按组累计"后再拼?——先 groupby+transform 拼回右表,再 merge,不要一边 merge 一边聚合。
3. pivot_table 与 crosstab
题目 3.1:pivot_table 和 crosstab 有什么区别?什么时候用哪个?
考点分析:两者都是透视表。pivot_table 是 DataFrame 的方法,参数全;crosstab 是顶层函数,专门做"频次交叉表",默认统计计数。
df = pd.DataFrame({
"城市": ["北京", "北京", "上海", "上海", "广州"],
"产品": ["A", "B", "A", "B", "A"],
"销量": [100, 80, 90, 70, 60],
})
# ---------- pivot_table:万能透视表 ----------
pt = pd.pivot_table(
df,
index="城市", # 行
columns="产品", # 列
values="销量", # 值
aggfunc="sum", # 聚合函数(默认 mean!)
fill_value=0, # 空单元格填 0
margins=True, # 加合计行列
)
print(pt)
# 产品 A B All
# 城市
# 上海 90 70 160
# 北京 100 80 180
# 广州 60 0 60
# All 250 150 400
# ---------- crosstab:默认统计"计数",最适合看分布 ----------
ct = pd.crosstab(df["城市"], df["产品"], margins=True)
print(ct)
# 产品 A B All
# 城市
# 上海 1 1 2
# 北京 1 1 2
# 广州 1 0 1
# All 3 2 5
# 想算占比?normalize 参数
print(pd.crosstab(df["城市"], df["产品"], normalize="index"))
追问与延伸:
- pivot_table 默认 aggfunc 是什么?——mean!这是大坑:不写
aggfunc="sum"出来的均值不是你要的。 - pivot 和 pivot_table 区别?——
pivot要求键唯一(无聚合),有重复键直接报错;pivot_table是pivot的聚合版本,重复键自动分组。 - 长表转宽表?——
pivot/pivot_table;宽表转长表?——melt。
4. 时间序列:重采样 / rolling / shift
题目 4.1:resample、rolling、shift 分别解决什么问题?
考点分析:时间序列三剑客,面试高频。
- resample:改变时间粒度(日 → 月、5 分钟 → 1 小时),类似 groupby 但按时间窗口。
- rolling:滚动窗口(最近 7 天均值),窗口是"滑动"的。
- shift:把时间轴平移(今天 vs 昨天、同比环比)。
# 构造日频销售数据
idx = pd.date_range("2024-01-01", periods=10, freq="D")
df = pd.DataFrame({"销量": [10, 12, 9, 15, 18, 14, 20, 22, 19, 25]}, index=idx)
# ---------- resample:日 -> 周/月 ----------
print(df.resample("W").sum()) # 按周求和
print(df.resample("ME").mean()) # 按月求均值('M' 新版弃用,用 'ME')
# ---------- rolling:滚动窗口 ----------
df["7日均值"] = df["销量"].rolling(window=3).mean() # 最近 3 天均值(含当天)
df["7日均值_中心"] = df["销量"].rolling(window=3, center=True).mean() # 窗口居中
print(df.head(5))
# 注意:前 2 行因为凑不齐 3 天,rolling 结果是 NaN
# ---------- shift:平移 ----------
df["昨天销量"] = df["销量"].shift(1) # 向下移一行(昨天)
df["前天销量"] = df["销量"].shift(2)
df["环比变化"] = df["销量"] - df["销量"].shift(1)
df["环比增长率"] = df["销量"].pct_change() # 等价于 (销量 - shift(1)) / shift(1)
# 反向移:shift(-1) 取下一行(明天)
df["明天销量"] = df["销量"].shift(-1)
追问与延伸:
- resample 和 groupby 区别?——resample 要求 index 是 DatetimeIndex;
df.resample("M").sum()等价于df.groupby(df.index.to_period("M")).sum()。 - rolling 的
min_periods参数?——窗口凑不齐时也算(只要有 N 个非空值),解决开头 NaN 太多的问题。 - 同比(YoY)怎么算?——
df["销量"] / df["销量"].shift(365) - 1。
题目 4.2:时间索引怎么创建和切分?
考点分析:考察 DatetimeIndex 的基本功。
# 三种创建方式
pd.date_range("2024-01-01", "2024-01-31", freq="D") # 起止 + 频率
pd.date_range("2024-01-01", periods=10, freq="H") # 起点 + 条数
pd.to_datetime(["2024-01-01", "2024-02-01"]) # 从字符串转
# 把时间列设为索引
df = pd.DataFrame({"销量": [10, 20]}, index=pd.date_range("2024-01-01", periods=2, freq="D"))
# 按年份/月份切片(非常方便)
# df.loc["2024"]:取 2024 全年
# df.loc["2024-01"]:取 1 月
# df.loc["2024-01":"2024-03"]:取一季度
# 提取时间分量
df["年"] = df.index.year
df["月"] = df.index.month
df["星期几"] = df.index.dayofweek # 0=周一
df["是否周末"] = df.index.dayofweek >= 5
追问与延伸:
- 时区怎么办?——
pd.to_datetime(...).tz_localize("Asia/Shanghai").tz_convert("UTC")。 - 频率字符串速查?——D=日、H=小时、T=分钟、S=秒、W=周、ME=月末、QE=季末、YE=年末。
5. 性能优化与向量化
题目 5.1:pandas 里怎么写最快?为什么 iterrows 慢?
考点分析:考察性能意识。核心结论:向量化操作 >> apply >> itertuples >> iterrows >> for 循环。
df = pd.DataFrame({"价格": np.random.rand(100000), "数量": np.random.randint(1, 10, 100000)})
# ---------- 慢写法(面试反面教材) ----------
# iterrows:把每行转成 Series,慢 100 倍以上
# total = 0
# for idx, row in df.iterrows():
# total += row["价格"] * row["数量"]
# apply(axis=1):还是慢,每次函数调用有开销
# df.apply(lambda r: r["价格"] * r["数量"], axis=1)
# ---------- 快写法 ----------
# 1. 向量化:整列直接算,底层走 C 层,快 100~1000 倍
df["总额"] = df["价格"] * df["数量"]
# 2. numpy 底层:还想再快,直接 values
df["总额"] = df["价格"].values * df["数量"].values
# 3. 条件赋值用 np.where(比 loc 快)
df["档位"] = np.where(df["价格"] > 0.5, "高", "低")
# 4. 多个条件用 np.select
df["分档"] = np.select(
[df["价格"] > 0.8, df["价格"] > 0.5],
["高档", "中档"],
default="低档",
)
# 5. 必须循环时:用 itertuples 而不是 iterrows
# for row in df.itertuples():
# ... row.价格 ...
追问与延伸:
eval/query什么时候快?——超大 DataFrame(百万行以上)做列间运算时,df.eval("价格 * 数量")走 numexpr,省中间内存。- 类别列加速?——低基数文本列
.astype("category")后 groupby/merge 能快好几倍。 - 内存优化?——读数据时
dtype={"城市": "category"}直接指定;df.info(memory_usage="deep")看真实占用。
题目 5.2:df.apply 和向量化相比,什么时候还值得用?
考点分析:辩证看 apply——它慢,但不是一无是处。
# 适用 apply 的场景:
# 1. 逻辑复杂、难以写成列表达式(比如嵌套字典解析、多条正则组合)
# 2. 行数不大(几千行以内),性能无所谓,可读性优先
# 3. 配合 groupby 做"每组返回 DataFrame"的复杂变换
# 反例(应该改向量化):
# df["C"] = df.apply(lambda r: r["A"] + r["B"], axis=1)
# 改写:
# df["C"] = df["A"] + df["B"]
追问与延伸:
- 面试话术?——"我会先判断能不能向量化;向量化写不出来(逻辑复杂)才用 apply;数据量百万级以上会特意 profile。"
6. MultiIndex 高级操作
题目 6.1:groupby 出来的结果是 MultiIndex,怎么"拍平"?
考点分析:这是 groupby 的常见后续问题,详见第 17 章。
df = pd.DataFrame({
"区域": ["华东", "华东", "华南", "华南"],
"城市": ["上海", "杭州", "广州", "深圳"],
"销量": [100, 80, 90, 70],
})
# groupby 多键 -> 索引变成 MultiIndex
g = df.groupby(["区域", "城市"])["销量"].sum()
print(g)
# 区域 城市
# 华东 上海 100
# 杭州 80
# 华南 广州 90
# 深圳 70
# ---------- 拍平方式一:reset_index()(最常用)----------
print(g.reset_index())
# 区域 城市 销量
# 0 华东 上海 100
# 1 华东 杭州 80
# ---------- 方式二:groupby 时直接 as_index=False ----------
print(df.groupby(["区域", "城市"], as_index=False)["销量"].sum())
# ---------- 多层列怎么拍平 ----------
pt = df.pivot_table(index="区域", columns="城市", values="销量", aggfunc="sum")
pt.columns = [f"销量_{c}" for c in pt.columns] # 手动拼列名
pt = pt.reset_index()
print(pt)
追问与延伸:
xs和loc区别?——xs("华东")抽截面自动丢层;loc["华东"]保留层级。stack/unstack?——unstack 把行 MultiIndex 变列(长转宽),stack 反过来(宽转长)。
7. 进阶考点速查表
| 考点 | 必会 API | 一句话记忆 |
|------|----------|-----------|
| 分组聚合 | g.agg(["sum","mean"]) | 每组压缩成一行 |
| 分组广播 | g["列"].transform("sum") | 行数不变,算占比神器 |
| 分组过滤 | g.filter(lambda x: ...) | 整组留或删 |
| 灵活分组 | g.apply(func) | 返回任意形状 |
| 内连接 | merge(..., how="inner") | 只留两边都有的 |
| 左连接 | merge(..., how="left") | 左表全保留 |
| 笛卡尔积坑 | merge(..., validate="m:1") | 提前报警 |
| 透视表 | pivot_table(..., aggfunc="sum") | 默认 mean,必写 aggfunc |
| 频次表 | pd.crosstab(a, b) | 默认计数 |
| 重采样 | df.resample("M").sum() | 日 -> 月 |
| 滚动窗口 | df.rolling(7).mean() | 最近 N 天均值 |
| 平移 | df.shift(1) / pct_change() | 算环比同比 |
| 向量化 | df["A"] * df["B"] | 永远第一选择 |
| 条件赋值 | np.where(cond, a, b) | 比 loc 快 |
| 拍平索引 | reset_index() | groupby 后必做 |
8. 易错点总结与自测练习
易错点 Top 10
1. agg 当 transform 用 —— 行数对不上、广播不回去;算占比一律 transform。
2. merge 前不查键唯一性 —— 偷偷笛卡尔积,行数暴涨;用 validate= 兜底。
3. pivot_table 忘写 aggfunc —— 默认 mean,出来的数是平均不是总和。
4. resample 用旧频率字符串 —— M/Q/A 在新版弃用,用 ME/QE/YE。
5. rolling 开头一堆 NaN —— 加 min_periods=1。
6. shift 方向搞反 —— shift(1) 是"上一行"(昨天),shift(-1) 是"下一行"(明天)。
7. iterrows 处理大数据 —— 十万行以上必慢,换向量化或 itertuples。
8. groupby 结果不 reset_index —— MultiIndex 直接拿去做 plot/merge 容易乱。
9. merge 后行数对不上预期 —— 先 df1["键"].duplicated().sum() 查重复键。
10. filter 和 transform+mask 混用 —— filter 是整组删;要保留部分行用 transform 算 mask。
自测练习
1. 给定销售表(部门、姓名、工资),用 transform 算"每个人工资占部门总额的比例"。
2. 写出 left join 和 inner join 的代码,并说明为什么 left join 结果里有 NaN。
3. merge 后从 1000 行变成 5000 行,说出两种排查手段。
4. 用 pivot_table 做出"城市 × 产品"的销量汇总,并打开 margins 看合计。
5. 把日频数据 resample 成周频求和,再算 7 日滚动均值。
6. 用 shift 算每天销售额的环比增长率,并对比 pct_change() 的结果。
7. 把 iterrows 版的"总价=单价×数量"改写为向量化版本。
8. groupby(["部门","城市"]) 后如何把结果变回普通 DataFrame(两种方法)。
下一篇预告:第 32 章 pandas 面试题精讲:实战场景篇 —— 把基础篇和进阶篇的知识点串到真实业务场景里:用户留存、AB 实验、异常检测、漏斗分析等高频面试题。
文章回复
0 条公开回复