简介:面向数据分析与Python开发岗位求职者整理的Pandas面试题速查文档,按基础、中级、高级与额外探讨四个层次收录60余道高频问题,从数据结构、文件读写一路延伸到性能调优,难度逐层递进。压缩包内为1个PDF文件,约263KB,体积轻便,手机与电脑都能随手翻阅,通勤或面试前突击都合适。内容上每题先给简练结论,再配可直接复用的代码片段,覆盖Series与DataFrame结构、CSV读取、条件筛选、表连接与合并、分组聚合、透视表、多级索引、分箱、滚动窗口计算,也涉及日期转换、宽长表变形、交叉表与表达式查询等考点。另有一部分专门讨论类别类型压缩、分块读取等内存与性能优化思路,帮助读者在答题时说出取舍理由而非只背结论。目前已有1288人学习下载,适合面试前集中过题、对照自查知识盲区,也可作为日常数据清洗与预处理的查阅清单。
1. 从一道卡壳的 groupby 追问说起
一个候选人被问到“groupby 之后既要总数又要均值,怎么一次算出来”,他先把agg写完,然后卡在了“求和之后怎么再除以计数”上——不是不会 Pandas,而是没想清楚聚合结果的行索引是什么。这份 pandas 面试题单的价值就在这类地方:它把 90 道题从基础、中级、高级一直排到额外探讨,表面上考 API,真正筛的是三件事——索引对齐、类型推断、内存账。基础段问read_csv、选列、排序、合并;中段问MultiIndex、pivot_table、melt、apply;高段直接问Categorical、chunksize、eval、内存优化。适合两类人:准备数据岗面试、想在三小时内把易错点过一遍的;以及已经天天写 Pandas、但发现代码越写越慢、想回头补基本功的。当翻阅答案索引来准备面试时,重点不应是背诵 API,而是弄清每个方法背后的索引与类型语义。
2. 基础 20 题的隐藏分水岭:结构、类型与选择器
很多人觉得基础段是送分题,head()、dropna()、set_index()谁不会。真到线上排查时,出问题的恰恰是这几行:某一列莫名其妙变成object,merge之后行数翻了三倍,reset_index之后原来的索引没了又找不回来。基础段真正该练的是“每写一行都知道返回的是什么形状、什么索引、什么 dtype”,这三样定了,中级和高级的题才有落脚点。
2.1 Series 与 DataFrame:一维和二维不是简单升降维
第一题问基本数据结构,标准答案是 Series 是一维带标签数组、DataFrame 是二维表格。但实际影响后面所有代码的,是取列的两种写法返回的根本不是同一个东西。
import pandas as pd orders = pd.DataFrame({ 'order_id': [1001, 1002, 1003, 1004], 'city': ['A城', 'B城', 'A城', 'C城'], 'amount': [120.5, 88.0, 300.0, 55.5], }) s = orders['amount'] # Series:只有一列,name 是 'amount',索引沿用原索引 f = orders[['amount']] # DataFrame:列名保留,即使只有一列也是二维 print(type(s), s.name) # <class 'pandas.core.series.Series'> amount print(type(f), f.shape) # <class 'pandas.core.frame.DataFrame'> (4, 1) # 要做 merge 或者 concat,Series 必须先显式变成 DataFrame 才能指定列名 avg_by_city = s.groupby(orders['city']).mean().to_frame(name='avg_amount') print(avg_by_city)这段的关键在最后一行。groupby().mean()返回的是以分组键为索引的 Series,to_frame(name=...)把它变成单列 DataFrame 并给列命名。如果不加这一步直接merge,pandas 会拿amount这个列名去和右表的列名匹配,十有八九报KeyError,而报错信息只会说找不到某列,不会提示你“其实你传的是个 Series”。
选型上的理由很直接:只要后面要做列对齐、合并、写文件,就统一用 DataFrame,别在中途用 Series 省那点字符。Series 适合的场景是明确的单列运算链,比如orders['amount'].clip(lower=0).sum(),运算完就消化掉了。
还有一个更隐蔽的点是索引对齐,两个 Series 相加按标签对齐,不按位置。
a = pd.Series([1, 2, 3], index=['x', 'y', 'z']) b = pd.Series([10, 20, 30], index=['y', 'z', 'x']) print((a + b).to_dict()) # {'x': 31, 'y': 12, 'z': 23},按索引标签相加如果两边的索引不完全一致,结果里会凭空多出NaN。这是很多人第一次遇到“算出来一堆空值”的根因,跟缺失数据一点关系都没有。处理办法要么在运算前align,要么在合并前统一reset_index(drop=True),二选一,不要指望 pandas 猜你的意图。
2.2 从 read_csv 开始就把类型钉死
第 2 题问怎么读 CSV,答案一行pd.read_csv就完事。但真实项目里,读进来的第一秒就决定了后面所有的性能和正确性。我一般会把dtype和parse_dates一起写死在读取这一步,而不是读进来再astype。
import pandas as pd dtype_map = { 'order_id': 'int64', 'user_id': 'int32', 'city': 'category', 'amount': 'float32', 'created_at': 'string', } df = pd.read_csv( 'orders.csv', usecols=list(dtype_map.keys()), # 只读需要的列,能省一大半 IO 和内存 dtype=dtype_map, # 读取时就定型,避免先 object 再转换 parse_dates=['created_at'], # 直接解析成 datetime64,避免字符串比较 na_values=['', 'NULL', 'None', '-'], # 自定义缺失值标记 keep_default_na=True, nrows=None, # 调试时先 nrows=1000 看结构 )参数逐个说:usecols是最容易被忽略的一刀,宽表里往往一半列根本用不上,先砍掉再谈别的优化;dtype里把user_id定成int32而不是默认int64,一百万行就能省一半;city直接给category,低基数列在分组和去重上会快很多;parse_dates比读完之后pd.to_datetime更稳,因为它在解析阶段就能处理混合格式并给出统一结果;na_values是血泪经验,业务系统导出的空值经常是NULL、-、/混着来,不提前声明,后面isna()统计出来的缺失率就是错的。
这里有个常见误用:很多人为了“保险”把所有列都读成object,觉得类型转换放到后面更灵活。代价是内存翻几倍,而且任何数值运算都要先转一次,groupby的哈希也变慢。读取时定型看着麻烦,其实是把复杂度前置到一个地方,后面全程受益。
2.3 选择器:loc、iloc、at、iat、query 各管一段路
第 4、5、74、82 题都在问怎么选数据。这几个选择器不是替代关系,各有明确的分工,混用就会出问题。
# loc:按标签选,行列都支持布尔条件 sub = df.loc[df['city'] == 'A城', ['order_id', 'amount']] # iloc:按位置选,左闭右开 head_rows = df.iloc[0:3, 0:2] # at / iat:单个标量,比 loc/iloc 快,但只能取一个格子 one_value = df.at[0, 'amount'] one_pos = df.iat[0, 2] # query:表达式过滤,列名当变量用,外部变量要加 @ big = df.query('amount > 100 and city == "A城"') threshold = 90 big2 = df.query('amount > @threshold')对比一下适用边界更清楚:
| 选择器 | 行维度 | 列维度 | 返回值 | 适用场景 |
|---|---|---|---|---|
| loc | 标签 / 布尔 | 标签 | Series 或 DataFrame | 条件筛选、按索引取值 |
| iloc | 整数位置 | 整数位置 | Series 或 DataFrame | 取前 N 行、按列序定位 |
| at | 单个标签 | 单个标签 | 标量 | 循环里取单值 |
| iat | 单个位置 | 单个位置 | 标量 | 已知坐标取单值 |
| query | 表达式 | 全部 | DataFrame | 多条件组合、条件可读性优先 |
query的性能优势只在表足够大时才体现,几千行的时候它和布尔索引没差别,但可读性确实好,多条件写起来不会出现一长串括号。注意query里字符串要用引号包住,外部变量必须加@,不加会被当成列名,然后报一个看不出问题在哪的UndefinedVariableError。
2.4 缺失值与索引:dropna、fillna、set_index、reset_index 的语义
第 9、8、12、84 题是一组。缺失值处理的核心不是记住两个方法名,而是先判断“这个缺失意味着什么”。
# 先看每列缺多少,再决定策略 print(df.isna().sum()) # 关键列缺了就丢,非关键列可以填 df = df.dropna(subset=['order_id', 'amount']) df['city'] = df['city'].cat.add_categories(['未知']).fillna('未知') # 设置索引:drop=True 表示原列不再保留为普通列 df = df.set_index('order_id', drop=True) # 重置索引:drop=True 丢弃旧索引,drop=False 把旧索引变回普通列 df = df.reset_index(drop=False)set_index之后,索引名就是列名,loc可以直接用df.loc[1001]取行,比布尔筛选快。但要注意set_index默认inplace=False,写了inplace=True之后原对象被改掉,如果这个 DataFrame 是某个函数参数传进来的,调用方会莫名其妙发现自己的数据变了。我的习惯是永远不用inplace,一律用赋值接收返回值,链式调用时更安全。
fillna对category列有个坑:填的值如果不在 categories 里会直接报错,所以上面那行先cat.add_categories(['未知'])再填。这就是“知道方法”和“知道类型约束”的差别。
3. 中级 20 题的分水岭:合并、分组与重塑
中级段是整个题单里最像真实工作的部分。合并、分组、重塑这三件事几乎构成了日常数据处理的全部骨架,而它们共同的难点是索引和形状——合并后行数变了、分组后列变成了多级、重塑后宽表长表来回倒。这一段练熟了,高级段的内存优化才有意义,因为优化的是正确的代码,不是把错的东西跑得更快。
3.1 merge、join、concat:三种合并的语义完全不同
第 7、25、29、56 题都在问合并。最常见的错误是把三者当同义词用,结果行数对不上还找不到原因。
orders = pd.DataFrame({ 'order_id': [1001, 1002, 1003], 'user_id': [1, 2, 1], 'amount': [120.5, 88.0, 300.0], }) users = pd.DataFrame({ 'id': [1, 2, 3], 'city': ['A城', 'B城', 'C城'], }) merged = pd.merge( orders, users, left_on='user_id', right_on='id', how='left', # 保留左表全部行 suffixes=('_o', '_u'), # 同名列加后缀,避免 _x/_y 这种不可读名字 validate='many_to_one', # 提前校验右表键是否唯一,行数膨胀会立刻报错 indicator=True, # 多出一列 _merge,标记匹配来源 ) print(merged['_merge'].value_counts()) print(merged.shape)参数说明:how有inner、left、right、outer,默认是inner,很多人不写就默认丢掉了未匹配行;left_on/right_on用于两边键名不同的情况,合并后两个键列都会保留,通常要drop掉一个;suffixes只在列名冲突时生效,默认是_x/_y,显式指定能省掉后面重命名的麻烦;validate是最值得养成的习惯,取值有one_to_one、one_to_many、many_to_one、many_to_many,写上去之后如果假设不成立会直接抛MergeError,而不是安静地给你一张三倍行数的表。
join默认按索引合并,concat是按轴堆叠。三者的差别用一段代码就清楚了:
# join:默认 left,按索引对齐 left = orders.set_index('user_id') right = users.set_index('id') joined = left.join(right, how='left', lsuffix='_o', rsuffix='_u') # concat:纵向堆叠,ignore_index 重排索引 batch1 = orders.iloc[:2] batch2 = orders.iloc[2:] stacked = pd.concat([batch1, batch2], axis=0, ignore_index=True) # concat 横向:按索引对齐,等价于 outer join side_by_side = pd.concat([batch1, batch2], axis=1)concat(axis=1)最容易翻车,因为它是按索引对齐的,如果两张表索引不一致,结果里会有一堆NaN,看起来像数据丢失,其实是标签没对上。批量处理分片数据时,我更倾向用concat(axis=0, ignore_index=True),明确知道自己在堆行,不会有对齐问题。
3.2 groupby 的 agg 与“求和之后再求平均”
第 16、46、60、77 题是一组连环问。先说结论:groupby().agg()返回的是一张以分组键为索引的表,列名可能是多级的,后续运算必须基于这个结构来写。
g = orders.groupby('city', observed=True) # 命名聚合:列名用关键字指定,不会产生 MultiIndex 列 stats = g.agg( total=('amount', 'sum'), cnt=('order_id', 'count'), avg=('amount', 'mean'), ) # 复合运算:先求和再求平均,本质是聚合结果的列间运算 stats['avg_per_order'] = stats['total'] / stats['cnt'] print(stats)命名聚合(关键字 = 元组)是这几年我最推荐的写法,原因是老写法g['amount'].agg(['sum', 'mean'])会产生多级列索引,取列要写stats[('amount', 'sum')],很容易写错。命名聚合直接给了一级列名,后面stats['total']就能取到。
复合运算的关键理解是:agg已经把每个分组压成了一行,total和cnt都是这一行上的标量列,两列相除就是逐行的向量化运算,不需要再套一层apply。很多人在这里写g.apply(lambda x: x['amount'].sum() / len(x)),结果对但慢一个数量级,因为apply是逐组走 Python 函数。
如果要把聚合结果广播回原表,也就是“每行都带上它所在城市的均值”,用transform:
orders['city_avg'] = orders.groupby('city', observed=True)['amount'].transform('mean') orders['diff_from_avg'] = orders['amount'] - orders['city_avg']transform返回和原表等长的 Series,索引对齐,可以直接赋值成新列。agg是降维,transform是保维,这两个混了就会出现“赋值之后全是 NaN”的现象。
observed=True这个参数值得单独说一句。当分组键是category类型时,默认会把所有类别组合都列出来,哪怕某些组合在数据里根本没出现过,结果就是一堆计数为 0 的空行。加上observed=True只保留实际出现的组合,表小很多,也符合直觉。
3.3 重塑:pivot_table 与 melt 是一对反向操作
第 19、34、48、65、66、72、75 题全在问形状变换。理解方式很简单:pivot_table是长表转宽表,melt是宽表转长表,stack/unstack是在索引和列之间搬数据。
long_df = pd.DataFrame({ 'city': ['A城', 'A城', 'B城', 'B城'], 'channel': ['线上', '线下', '线上', '线下'], 'amount': [100, 50, 80, 20], }) # 长转宽:重复的 (index, columns) 组合必须有 aggfunc 决定怎么合并 wide = long_df.pivot_table( index='city', columns='channel', values='amount', aggfunc='sum', fill_value=0, margins=True, margins_name='合计', ) # 宽转长:把宽表的列名变成一个变量 back = wide.reset_index().melt( id_vars='city', value_vars=['线上', '线下'], var_name='channel', value_name='amount', )参数上:pivot_table的aggfunc默认是mean,如果你的数据里(city, channel)组合唯一,用pivot更直接,但一旦有重复就会抛ValueError,这也是pivot和pivot_table最本质的区别——前者假设唯一,后者负责聚合;margins=True会加一行一列汇总,做报表很省事;fill_value=0避免缺失组合出现NaN,但要注意这会把“真的没有”和“数值为零”混为一谈,分析口径上要提前想清楚。
stack/unstack更适合处理多级索引:
stacked = wide.stack() # 列名下沉成最内层索引,结果变 Series unstacked = stacked.unstack(level='channel') # 再抬回列stack之后是 Series 而不是 DataFrame,这点很多人第一次用会愣一下。它适合做“把宽表压平再做统一计算”的场景,比如把所有渠道的金额一次性算占比。
3.4 map、apply 与向量化:性能边界在哪
第 21、27、37 题问函数应用。三个方法的定位要分清:Series.map是逐元素映射,适合字典替换;DataFrame.apply(axis=1)是逐行走 Python 函数,最慢,能不用就不用;DataFrame.applymap是整表逐元素,只在做统一格式化时用。
# map:字典或函数映射,效率高,是替换编码的首选 city_code = {'A城': 1, 'B城': 2, 'C城': 3} orders['city_code'] = orders['city'].map(city_code) # 分箱用 cut,比 apply 里的 if-else 快得多 orders['level'] = pd.cut( orders['amount'], bins=[0, 100, 250, float('inf')], labels=['低', '中', '高'], right=False, ) # 逐行 apply 只在逻辑确实复杂时用,并且尽量缩小输入列 def classify(row): if row['amount'] > 200 and row['city'] == 'A城': return '重点' return '普通' orders['tag'] = orders.apply(classify, axis=1)apply(axis=1)慢的原因是它把每一行包成一个 Series 再调函数,一行一次 Python 调用开销,十万行就是十万次。能用np.where、pd.cut、map、布尔索引组合表达的,一律不写apply。真要用,也先把不需要的列剔掉再传,减少每行构造 Series 的体积。
4. 高级 20 题的内存账:dtype、分块与 eval
高级段开始问“DataFrame 很大加载不进内存怎么办”“怎么优化内存使用”。这些问题在面试里容易被答成口号,实际做的时候必须从测量开始——不看memory_usage就动手改 dtype,等于闭着眼睛调参。
4.1 先把内存量出来,再谈优化
第 41、42、86 题的核心动作是先量化。df.info()默认给的是浅层内存,object列只算指针大小,看起来很小,实际字符串内容全在别处。
# 浅层统计:object 列严重低估 df.info(memory_usage='deep') # 逐列看真实占用并排序,找出大头 usage = df.memory_usage(deep=True).sort_values(ascending=False) print(usage.head(10)) print(f'总计 {usage.sum() / 1024**2:.1f} MB')deep=True会真正遍历object列里的每个字符串,慢但准。我一般在大表上先跑一次,把占比最高的前五列列出来,优化就从这五列下手,其余不动。这一步做完,通常能发现 70% 的内存集中在两三列上,比如一个长文本列或者一个高基数 ID 列,针对性处理比全局降位有效得多。
4.2 降位与 category:省内存的两把刀
数值列的降位是有损的,得先确认取值范围。
import numpy as np # 有符号整数按实际范围降位 for col in ['user_id', 'order_id']: df[col] = pd.to_numeric(df[col], downcast='integer') # 浮点降位:float32 大约 7 位有效数字,金额场景要评估精度是否够 df['amount'] = pd.to_numeric(df['amount'], downcast='float') # 低基数字符串转 category,基数超过一半时反而更占内存 for col in ['city', 'channel']: if df[col].nunique() / len(df) < 0.5: df[col] = df[col].astype('category')downcast='integer'会自动选能容纳当前范围的最小整型,比如int64变int8。风险在于后续如果读入更大的数据,会溢出回绕,所以只对已经确定范围的历史数据做,别写进通用的读数据函数里。
category的收益来自把字符串去重成字典加整数编码,所以基数低才划算。判断标准就是上面那个比例,一般唯一值占比低于 30% 到 50% 就值得转。转完之后groupby会快,但要注意前面提过的observed参数,以及category列不能直接做数值运算,得先astype。
4.3 读不进来就分块:chunksize 的正确姿势
第 59、85、88 题问的是同一件事。分块处理的关键不是chunksize这个参数本身,而是“分块之后哪些聚合可以合并、哪些不能”。
import pandas as pd dtype_map = {'order_id': 'int64', 'city': 'category', 'amount': 'float32'} parts = [] for chunk in pd.read_csv('big_orders.csv', chunksize=200_000, dtype=dtype_map): part = chunk.groupby('city', observed=True).agg( total=('amount', 'sum'), cnt=('order_id', 'count'), ) parts.append(part) # 分块结果再合并:sum 和 count 可加,mean 不可加 res = pd.concat(parts).groupby(level=0, observed=True).sum() res['avg'] = res['total'] / res['cnt']这里最关键的注释是最后两行。求和和计数是可加的,分块算完再合并没问题;但均值不可加,直接对每块的均值再求平均是错的,必须先累加total和cnt,最后除一次。这个错误非常隐蔽,因为结果看起来是个合理的数字,只有当各块行数差异大时才会明显偏离。我第一次遇到时对了两天才发现。
chunksize的取值没有标准答案,一般按内存的十分之一到五分之一估,几十万行是个常见起点。太小会让 IO 次数变多,太大又失去了分块的意义。实际调的时候看一眼单块处理耗时,控制在几百毫秒到一两秒之间比较舒服。
4.4 eval 与 query:什么时候真能加速
第 89 题问eval。它走的是表达式引擎,对大表的算术运算和条件过滤有加速,对小表反而慢,因为多了一层解析开销。
# 新增派生列,避免中间临时对象 df = df.eval('total = price * qty') # 复杂过滤,@ 引用外部变量 limit = 1000 filtered = df.query('total > @limit and city == "A城"')判断是否值得用,我的经验是看行数和表达式长度:十万行以上、表达式里有两三个算子的时候,eval有肉眼可见的收益;几千行的时候差别在毫秒级,不如优先保证可读性。
| 优化手段 | 典型收益 | 不该用的场景 |
|---|---|---|
| 读取时指定 dtype | 内存降 30% 到 70% | 列类型尚未确定的探索阶段 |
| category 编码 | 分组、去重变快,内存下降 | 唯一值占比超过一半的列 |
| chunksize 分块 | 突破内存上限 | 需要全表排序或跨行窗口的计算 |
| eval / query | 大表算术与过滤加速 | 小表、表达式含自定义函数 |
5. 排查清单:五个最常见的翻车现场
下面这几条都是我在真实项目里反复遇到的,题单里对应的编号我都标上了,复习的时候可以对着看。
5.1 链式赋值不生效,还蹦出 SettingWithCopyWarning
现象:写了df[df['amount'] > 100]['level'] = '高',跑完不报错,但df里什么都没变,控制台还飘一条警告。
原因:df[条件]返回的是原表的一个切片视图,对视图赋值要么被静默丢弃,要么改的是临时对象。pandas 无法确定你要改原表还是改副本,所以给警告。
解决:一步到位用loc,把行条件和列选择写在同一次索引里。
df.loc[df['amount'] > 100, 'level'] = '高'如果确实要先取子集再改,明确写.copy(),然后改副本,改完再决定要不要合并回去。别用inplace=True试图绕过,那只是把警告藏起来。
5.2 merge 之后行数莫名变多
现象:左表 10 万行,右表 5000 行,merge完变成 13 万行。
原因:右表的连接键不唯一,一对多匹配把左表行复制了。默认的how='inner'又刚好把不匹配的行丢掉了,两个效应叠在一起,行数既不是左表也不是右表。
解决:合并前先查右表键的唯一性,合并时显式加validate。
assert users['id'].is_unique, '右表键有重复' merged = pd.merge(orders, users, left_on='user_id', right_on='id', how='left', validate='many_to_one', indicator=True) print(merged['_merge'].value_counts()) # 看有多少 left_onlyindicator=True能立刻告诉你匹配率,比事后猜靠谱。
5.3 日期列解析出 NaT,分组结果少了一大块
现象:pd.to_datetime之后一堆NaT,按日期分组时这些行直接消失。
原因:源数据里日期格式混着来,有2024-01-05、2024/01/05、还有带时间的,默认解析器遇到不认识的格式就置空。
解决:显式给format,或者用errors='coerce'先把问题行筛出来看看到底长什么样,别一上来就coerce掩盖问题。
raw = pd.to_datetime(df['created_at'], errors='coerce') bad = df.loc[raw.isna(), 'created_at'].unique()[:10] print(bad) # 先看坏样本长什么样,再决定 format df['created_at'] = pd.to_datetime(df['created_at'], format='mixed', errors='coerce')5.4 groupby 之后赋值错位,整列变成 NaN
现象:df['avg'] = df.groupby('city')['amount'].mean(),结果avg列大部分是NaN。
原因:mean()是聚合,返回的是以city为索引的短表,索引和原表对不上,赋值时按标签对齐,对不上的全变NaN。
解决:要广播回原表就用transform,要聚合结果就用agg并且单独存一张表。
df['avg'] = df.groupby('city', observed=True)['amount'].transform('mean')记住一句:降维用agg,保维用transform,别拿agg的结果往原表上贴。
5.5 用等号比较浮点数,筛选结果总是差几条
现象:df[df['amount'] == 0.3]筛不出本该有的行。
原因:浮点数在二进制里存不下精确的 0.3,读进来可能是 0.30000000000000004,等号比较自然不成立。
解决:金额类字段比较用容差,或者一开始就用整数分存储。
import numpy as np mask = np.isclose(df['amount'], 0.3, atol=1e-9) print(df[mask].shape)更彻底的做法是金额统一存成以分为单位的整数,展示时再除 100,从源头消掉这个问题。
6. 把它变成自检脚本:从背答案到验证
题单看完很容易产生“我都会了”的错觉,因为每一题的答案都短得让人安心。真正检验掌握程度的方式是把答案写成断言,让代码来判卷。我一般会让新人在过完中级段之后写一个这样的自检文件,跑通才算过关。
import pandas as pd import pandas.testing as pdt def build_fixture(): orders = pd.DataFrame({ 'order_id': [1001, 1002, 1003, 1004], 'user_id': [1, 2, 1, 3], 'city': ['A城', 'B城', 'A城', 'C城'], 'amount': [120.5, 88.0, 300.0, 55.5], }) users = pd.DataFrame({ 'id': [1, 2, 3], 'channel': ['线上', '线下', '线上'], }) return orders, users def test_merge_keeps_left_rows(): orders, users = build_fixture() merged = pd.merge(orders, users, left_on='user_id', right_on='id', how='left', validate='many_to_one', indicator=True) # 左连接行数必须和左表一致,这是合并正确性的第一道闸 assert len(merged) == len(orders) assert (merged['_merge'] == 'both').sum() == 4 def test_groupby_compound(): orders, _ = build_fixture() stats = orders.groupby('city', observed=True).agg( total=('amount', 'sum'), cnt=('order_id', 'count')) stats['avg'] = stats['total'] / stats['cnt'] # A城两条:120.5 + 300.0 = 420.5,均值 210.25 assert stats.loc['A城', 'total'] == 420.5 assert stats.loc['A城', 'avg'] == 210.25 def test_transform_broadcast(): orders, _ = build_fixture() orders['avg'] = orders.groupby('city', observed=True)['amount'].transform('mean') # 广播后行数不变,且不能有 NaN,这两条能同时抓住 agg 误用 assert len(orders) == 4 assert orders['avg'].isna().sum() == 0 def test_pivot_roundtrip(): orders, _ = build_fixture() wide = orders.pivot_table(index='city', columns='user_id', values='amount', aggfunc='sum', fill_value=0) back = wide.reset_index().melt(id_vars='city', var_name='user_id', value_name='amount') # 宽长往返后总和必须守恒,这是重塑是否写对的硬指标 assert abs(back['amount'].sum() - orders['amount'].sum()) < 1e-9这四个断言分别卡住四类高频错误:合并行数膨胀、聚合索引错位、agg当transform用、重塑丢数据。跑法就是普通的pytest -q,不需要额外依赖,pandas.testing还可以用来做整表比对,比手写循环断言省事。写断言的过程本身就是把“我记住了 API”逼成“我知道它返回什么形状”,这一步比刷十遍题管用。
从那以后我每次接新数据,都会先写三五行断言把行数、主键唯一性、时间范围钉死,再开始写业务逻辑。血泪经验是,数据问题越早暴露越便宜,等跑完一小时的任务才发现索引错了,那个后悔药是买不到的。这份题单我一般当检查表用,隔半年翻一遍,每次都能想起一两个已经忘掉的坑。希望帮到你。
本文还有配套的精品资源,点击获取