RSS
菜单
全部文章快讯开发科技深度热点

第 31 章 pandas 面试题精讲:进阶篇

内容摘要

本篇是 pandas 面试题精讲的进阶篇,专攻最能拉开差距的六大考点:groupby 聚合三兄弟(agg/transform/filter)、merge 的四种 join 方式、pivot_table 与 crosstab 的区别、时间序列的重采样/rolling/shift、向量化性能优化,以及 MultiIndex 高级操作。每道题按"题目 → 考点分析 → 参考答案代码(带注释)→ 追问与延伸"组织,文末配速查表与易错点。前置知识:已读完[第 13 章分组聚合](13-分组聚合:groupby.md)、[第 11 章数据合并](11-数据合并:concat-merge-join.md)、[第 14 章时间序列](14-时间序列分析.md)、[第 17 章 MultiIndex](17-高级索引与MultiIndex深度实战.md)。学完后你能从容应对中高级 pandas 面试。

📖 本篇简介:本篇是 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 实验、异常检测、漏斗分析等高频面试题。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 31 章 pandas 面试题精讲:进阶篇》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。