Pandas生产实践:索引对齐、视图副本与分组连接陷阱
简介:这份面向数据分析师、Python 学习者与求职者的 Pandas 面试题 PDF,围绕基础概念、常用操作和进阶特性组织内容,适合面试前系统复盘,也可作为日常开发中的速查清单。题目覆盖 Series 与 DataFrame、CSV 读写、条件筛选、排序合并、缺失值处理、分组聚合、透视表、时间序列、MultiIndex、内存优化、分块读取大型数据、与 SQL 交互、窗口函数及数据清洗等主题,并配有问答式要点和部分示例代码,便于对照理解常见考点、操作差异与易错细节。资源为单个 PDF 文件,约 263KB,目录按难度分层,可按知识点快速定位。已有 1288 人学习,适合初中级数据分析师和 Python 学习者查漏补缺。
1. 从「会用」到「用对」之间,隔着三条不写在报错里的规则
Pandas 的 API 其实不多,read_csv、loc、groupby、merge,认真背两天就能覆盖八成日常操作。真正在生产里让人翻车的,从来不是记不住函数名,而是三件根本不写进报错信息的事:你拿到的是视图还是副本、你用的是标签还是位置、两个对象的索引到底有没有对齐。这份按基础、中级、高级、额外探讨四层排布的 Pandas 题库,表面上问的是「怎么读 CSV」「怎么分组求和」,往下追一层,几乎每一道都在考这三件事。搞懂它们,几十道题会收敛成几条主线;搞不懂,每道题都只是一个孤立的口诀,换个场景照样踩。下面从索引这件最容易被轻视、也最容易被误判的事开始拆。
2. loc 与 iloc:为什么同一个方括号会给出两种截然不同的结果
2.1 一个[]运算符,背了三种语义
很多人对 Pandas 的困惑,从df[...]就开始了。同一个方括号,传字符串是取列,传布尔序列是筛行,传切片是切行,传列名列表又是取多列。这不是设计者偷懒,而是__getitem__在不同输入类型上走了不同分支:字符串和列表走列选择,布尔数组和切片走行选择。分支一多,直觉就失效了——df[0]取的不是第 0 行,而是名为0的列;df[1:3]切的是物理位置第 1 到第 2 行,跟索引标签毫无关系。
正因为[]的语义随参数漂移,Pandas 才另外提供了loc和iloc这两个「把语义钉死」的入口:loc只认标签,iloc只认位置。它们是显式声明,不是语法糖。写业务代码时我基本不用裸[]做行选择,宁可多敲几个字符,也不让读代码的人去猜这一行到底按什么规则匹配。
import pandas as pd df = pd.DataFrame( {"city": ["A", "B", "C"], "sales": [10, 20, 30]}, index=[10, 20, 30], # 故意用整数当标签,制造歧义 ) print(df.loc[10:20]) # 标签切片,闭区间:命中标签 10 和 20,共 2 行 print(df.iloc[0:2]) # 位置切片,左闭右开:命中第 0、1 行,也是 2 行 print(df.iloc[10:20]) # 位置 10 已越界,返回空 DataFrame,不抛异常 print(df[10:20]) # 裸方括号的切片走位置,等价于 iloc[10:20],也是空上面这段代码里最关键的一行是df.iloc[10:20]:它不报错,只是安静地返回空表。这类「静默失败」比异常难查得多,因为下游拿到空表照样能跑完流程,最后错误出现在某个汇总数字上。loc的切片是闭区间,iloc是左闭右开,这个差异不是随手定的——标签切片面向「范围」语义(10 到 20 之间的所有标签,含两端),位置切片面向「长度」语义(取 2 个,像 Python 的 list),两套直觉各自自洽,混用才出问题。
2.2 Index 对象:不可变、可重复,还可能不是唯一的
loc能做到「按标签查」,靠的是Index对象。它有三个属性值得记住:第一,它是不可变的,df.index[0] = 5会直接抛错,改索引只能靠set_index、reset_index或rename重建;第二,它允许重复值,重复索引下df.loc[10]返回的不再是 Series 而是 DataFrame,形状突然从一维变二维,后面接.mean()之类的操作就可能算错维度;第三,它内部维护哈希映射来加速查找,所以标签查找接近常数时间,而iloc是纯位置偏移,连查表都省了。
不可变这个设计常被抱怨麻烦,但它是索引能安全共享的前提:多个 DataFrame 可以指向同一个 Index 对象而不必各自复制一份,切片、筛选时也只需要传递引用。代价就是你没法「就地改一个标签」,只能生成新索引。理解这一点,reset_index为什么默认返回新对象、inplace=True为什么在链式调用里容易出事,就都顺了。
一个常被忽略的细节是索引的name。groupby之后的结果、merge之后的键、pivot_table之后的列,都会带上name,这些名字会在后续reset_index时变成列名,也会在concat对齐时参与匹配。名字不一致本身不影响计算,但会让最终输出的列名变得莫名其妙,排查时多花半小时。
2.3 索引对齐:为什么两个数相加会凭空长出 NaN
这是 Pandas 里最反直觉、也最容易造成数据事故的机制:两个 Series 相加,不是按位置逐个相加,而是按索引对齐后相加。索引取并集,任一侧缺失就填 NaN。位置相同但标签不同,结果就是一片 NaN。
s1 = pd.Series([1, 2, 3], index=["a", "b", "c"]) s2 = pd.Series([10, 20, 30], index=["b", "c", "d"]) print(s1 + s2) # a NaN <- s2 没有 a # b 22.0 # c 33.0 # d NaN <- s1 没有 d这段代码里没有任何语法错误,但结果里一半是 NaN。如果这发生在对账场景,NaN 会被后续的sum()当成 0 跳过,最终总额少算一块,而且不报警。要避免,就得在运算前显式对齐:s1.add(s2, fill_value=0)会给缺失侧补 0 再算;或者干脆先reindex到同一个索引上再做算术。
DataFrame 与 Series 之间的运算还有一层:默认按列名对齐(axis="columns"),也就是 Series 的索引去匹配 DataFrame 的列名。很多人以为df - series是逐行减,实际上是逐列减,方向反了结果依然是个形状正确的表,只是数字全错。这类错误没有异常、没有警告,只能靠对机制的记忆来防。
2.4 at/iat 与 query:标量访问和表达式求值
at和iat是loc/iloc的标量特化版,专门用于取单个格子。它们省掉了返回 Series 的包装开销,在循环里逐个取值时比loc快一个量级。但要注意重复索引的坑:df.at[10, "sales"]在标签 10 重复时返回的是 Series 而不是标量,如果你的代码后面接了float()之类的转换,就会在这里炸掉。
query则是另一条路:把筛选条件写成字符串表达式,交给 numexpr 之类的后端求值。它的价值不只是「写起来像 SQL」,更在于减少中间布尔数组的内存分配——对百万行级别的表,df[df.a > 0]会先造一个等长的布尔数组,再拿它去切,而query可以在分块层面完成比较,峰值内存更低。外部变量用@前缀注入:
threshold = 15 # 字符串表达式里用 @ 引用 Python 变量,避免手工拼 f-string 造成注入或类型问题 result = df.query("sales > @threshold and city != 'C'") # 等价的布尔索引写法,会多产生一个长度为 len(df) 的临时布尔数组 result2 = df[(df["sales"] > threshold) & (df["city"] != "C")]两个参数值得留意:inplace在query上默认是 False,返回新对象;表达式里的列名如果含空格或特殊字符,要用反引号包起来。性能上,query的优势在中大型表上才体现得出来,几万行以内两者差别可以忽略,不必为了「看起来高级」而牺牲可读性。
3. groupby 的三段式:split-apply-combine 究竟在哪一步烧掉了时间
3.1 分组键的哈希:split 阶段到底做了什么
groupby是惰性的,写下df.groupby("dept")的那一刻什么都没算,只是构造了一个 Grouping 对象,记录了「按哪些列分、要不要排序、有没有观察到全部分组」。真正触发计算的是后面的聚合方法。split 阶段的核心是把分组键映射成组编号:对每一行的键值做哈希,得到该行属于第几组,最终产出一个与行数等长的整数编码数组,外加每组的起始位置和计数。
这套「编码 + 偏移」的设计,让 apply 阶段可以按组切片而不必真的把数据复制成若干个小表。分组键的取值分布直接影响这一步的代价:键的基数(不同取值个数)越大,哈希表越大,组内平均行数越少,后续每组一次的函数调用开销占比就越高。这也解释了一个经验:对几乎全唯一的列做groupby,性能会明显差于对低基数列分组,因为前者接近「每行一组」,Python 层的循环开销全暴露出来了。
默认sort=True会对组键排序,排序本身也是一次开销;如果你的下游不依赖组顺序,显式传sort=False能省一步。observed参数则在 Categorical 键上起作用:默认情况下即使某个类别在数据里一次都没出现,也会为它保留一个空组,聚合结果里多出一堆 NaN 行。知道这点,就不会对「为什么结果里冒出一批计数为 0 的组」感到困惑。
3.2 agg、transform、apply:返回形状决定用途
这三个方法的区别不在参数,而在返回形状,形状又决定了能不能直接赋回原表。
agg是降维聚合,每组返回一个标量,结果行数等于组数,索引是组键。它适合做汇总表,不能直接赋回原 DataFrame,因为长度对不上。transform是保形变换,每组算完后把结果广播回该组的每一行,返回对象与原表同形同索引,所以可以直接写成df["dept_mean"] = df.groupby("dept")["salary"].transform("mean"),这是做「组内均值填充缺失值」「组内标准化」的标准姿势。apply最灵活,每组拿到一个小 DataFrame,爱返回什么返回什么,但也最慢——它走的是 Python 层的函数调用,且返回形状不确定时还要额外做拼接推断。
df = pd.DataFrame({ "dept": ["A", "A", "B", "B", "B"], "name": ["n1", "n2", "n3", "n4", "n5"], "salary": [100, 200, 150, 150, 300], }) g = df.groupby("dept", sort=False) # 保形变换:结果长度与原表一致,可以直接落成新列 df["dept_mean"] = g["salary"].transform("mean") # apply:每组拿到一个子 DataFrame,返回标量时结果是一维 Series spread = g.apply(lambda x: x["salary"].max() - x["salary"].min())transform那行之后,每个 A 组行的dept_mean都是 150,每个 B 组行都是 200。如果换成agg("mean"),得到的是两行的结果,硬赋回五行的表会变成索引对齐后的广播,缺的位置全 NaN——这就是上一章讲的对齐机制在另一处的表现。apply那行返回的是以dept为索引的 Series,两行,适合做组间离散度分析。
3.3 命名聚合:把多级列名扼杀在摇篮里
g.agg(["sum", "mean"])会返回多级列索引:第一层是原列名,第二层是函数名。取数时要写result[("salary", "sum")]或者用xs降维,稍微复杂一点的分析就容易被这套层级绕晕。命名聚合(named aggregation)用关键字参数直接把输出列名定死,返回的是普通单层列,可读性和下游兼容性都好得多。
summary = g.agg( total=("salary", "sum"), # 输出列 total,取 salary 的求和 avg=("salary", "mean"), # 输出列 avg headcount=("name", "count"), # 不同列用不同函数,一行搞定 )参数形式是新列名=(源列名, 函数名或函数对象)。函数对象可以是自定义的,但自定义函数会走 Python 循环,比内置的sum/mean慢不少,能拆成内置组合就别自己写。复合运算(比如「先求和再求平均」)的思路也在这里:先用一次agg算出中间量,再在结果表上做列间运算,而不是在apply里一次性算完——后者可读性差,还难优化。
3.4 rolling、expanding、ewm:窗口的边界语义
窗口函数和groupby常被放在一起用,但它们的机制完全不同。rolling(window=n)维护一个长度固定为 n 的滑动窗口,每前进一行就丢头补尾;expanding()是累计窗口,从头累加到当前行;ewm()则给历史值按指数衰减加权。
最容易翻车的是min_periods。rolling(3)的默认min_periods等于窗口长度,意味着前两行必然是 NaN——不是数据缺失,而是窗口没攒够。如果业务上希望「有几个算几个」,必须显式传min_periods=1。groupby加rolling时,结果会多出一层索引(组键 + 原索引),这是为了区分不同组的窗口,直接拿去赋值会因为索引不匹配而错位,通常要.reset_index(level=0, drop=True)把组键那层去掉再对齐。
# 按组内时间顺序做 3 期滑动平均;min_periods=1 让首行也有值 # 结果带两层索引:第一层是 dept,第二层是原行索引 ma = ( df.sort_values(["dept"]) .groupby("dept")["salary"] .rolling(3, min_periods=1) .mean() .reset_index(level=0, drop=True) # 丢掉组键层,保留原索引 ) df["ma3"] = ma.sort_index() # 按原索引归位后才敢赋值这段代码里有三处防御性写法值得记住:先sort_values保证窗口按业务顺序滑动,reset_index(level=0, drop=True)去掉组键层,最后sort_index()把顺序还原。任何一步省掉,赋值都可能悄悄错位——不会报错,只会让某个组的滑动均值跑到另一组头上。
4. 连接与重塑:merge 的行数为什么会凭空翻倍
4.1 merge 的四种 how,和一对多的行数膨胀
merge的how参数决定保留哪些键:inner取交集,left保留左表全部,right反过来,outer取并集、缺失侧补 NaN。语义本身不难,难的是没人提醒你「一对多会膨胀」。左表一行对应右表三行,left连接的结果就是三行,行数从 1000 变 3000,然后你对结果做sum(),金额直接翻三倍。
更隐蔽的是键不唯一导致的重复匹配:业务上以为id唯一,实际有重复,一个 id 在两张表里各有两条,结果是笛卡尔积四行。这类问题靠肉眼查很难发现,靠validate参数可以直接在合并时抛错:
left = pd.DataFrame({"id": [1, 2, 3], "amount": [10, 20, 30]}) right = pd.DataFrame({"id": [1, 1, 2], "tag": ["x", "y", "z"]}) # validate 声明键的基数关系:1:m 表示左表键唯一、右表可重复 merged = left.merge(right, on="id", how="left", validate="1:m") # 若声明 validate="1:1" 而右表存在重复 id,会直接抛 MergeErrorvalidate的可选值是1:1、1:m、m:1、m:m,含义是在合并前检查键的唯一性约束。加上它,等于给连接操作装了一个断言:一旦数据分布和预期不符,当场失败,而不是让错误的行数流到下游。另外一个常被漏掉的参数是indicator=True,它会给结果加一列_merge,标出每行来自左表、右表还是双方,对账时非常有用。
4.2 join 与 merge 的分工,以及 concat 的两个轴
join默认按索引连接,merge默认按列连接,这是两者最本质的差别。join写起来短,适合索引本身就是业务主键的场景;merge参数更全,left_on/right_on可以分别指定两侧的键,键名不一致时只能用它。
concat则是「粘」,不是「连」。axis=0纵向堆叠,按列名对齐,某张表缺的列会被补 NaN;axis=1横向拼接,按索引对齐,索引对不上的行同样补 NaN。两种模式都遵循同一套对齐规则,所以「concat 之后数据变多或变空」的根因永远是索引或列名没对齐,而不是 concat 本身有 bug。纵向堆叠时如果不在意原索引,加ignore_index=True重新编号,能省掉后续一堆重复索引的麻烦。
# 纵向:按列名对齐,缺失列补 NaN;ignore_index 重排索引 all_rows = pd.concat([df_a, df_b], axis=0, ignore_index=True) # 横向:按索引对齐,索引不一致的位置补 NaN,行数取索引并集 side_by_side = pd.concat([df_x, df_y], axis=1)4.3 pivot_table 与 crosstab:一个聚合,一个计数
pivot不聚合,它只是把长表摆成宽表,遇到重复的「行 + 列」组合会直接报错;pivot_table默认用均值聚合,所以重复组合不报错,而是被合并。这个默认值很容易骗人:同一组数据用pivot报错、用pivot_table却出结果,看似的「修好了」,实际上是悄悄把多条记录平均掉了。
crosstab是pivot_table的特例,默认做计数,常用来出频次交叉表。三者关系理清之后,选型就简单了:要保留每一条原始记录用pivot,要聚合用pivot_table并显式写出aggfunc,要频次分布用crosstab。
# aggfunc 必须显式指定,别依赖默认的 mean pt = df.pivot_table( index="dept", columns="month", values="sales", aggfunc="sum", fill_value=0, margins=True, # margins 追加一行一列合计 )margins=True会额外生成「All」行列,做报表时省一次手算;fill_value=0把空组合填 0 而不是 NaN,避免下游求均值时被 NaN 污染。
4.4 melt 与 stack/unstack:在行轴和列轴之间搬数据
melt是宽变长:指定id_vars作为保留的标识列,其余列被压成「变量名 + 值」两列。stack和unstack则是把索引层级在行轴与列轴之间搬移——stack把最内层列索引压到行索引里,unstack反过来把最内层行索引提到列上。两者都依赖 MultiIndex,理解它们的前提是接受「索引层级可以在两个轴之间流动」这个模型。
stack默认会丢弃 NaN,这个默认行为经常导致「压完之后行数变少了」的困惑。需要保留缺失组合时传dropna=False。unstack则可能因为行索引不唯一而报错,因为提到列上之后无法确定每个格子该放哪个值。
wide = df.pivot_table(index="dept", columns="month", values="sales", aggfunc="sum") long = wide.stack(dropna=False) # 列索引压到行,保留空组合 back = long.unstack(level=-1) # 再提回列轴,level 指定搬哪一层多层索引下的筛选,IndexSlice比手写元组清晰得多:df.loc[pd.IndexSlice[:, "Q1"], :]表示「行索引第一层全要、第二层取 Q1」。手写df.loc[(slice(None), "Q1"), :]效果一样,但可读性差一截,而且层级一多就容易数错位置。
5. 排查清单:五类「代码没报错、结果却错了」的 Pandas 现场
5.1 链式赋值:改了等于没改
现象:df[df["a"] > 0]["b"] = 1执行完不报错,但df里b列纹丝不动,有时还会飘一条 SettingWithCopyWarning。
原因:df[布尔条件]先返回一个对象,这个对象是视图还是副本,Pandas 自己都不能保证,取决于内存布局。往它上面赋值,可能改的是原表,也可能改的是一份马上被丢弃的临时副本。
解决:永远用一次loc同时完成行列定位——df.loc[df["a"] > 0, "b"] = 1。这一行明确告诉 Pandas「在满足条件的行上,改 b 列」,不产生中间对象。
5.2 整数索引下 loc 的闭区间
现象:索引是 0 到 100 的整数,df.loc[0:10]返回 11 行,而df.iloc[0:10]返回 10 行,两个结果对不上。
原因:loc走标签闭区间,iloc走位置左闭右开。整数索引把两种语义的差异放大到肉眼可见。
解决:明确自己要做的是「按标签范围取」还是「按位置取 N 行」,选定一个入口后不要中途换。索引本身有业务含义时,优先loc。
5.3 对齐产生的 NaN
现象:两个 Series 相加、或者 DataFrame 减去一个 Series 之后,结果里出现大量 NaN,但两边的数据明明都齐全。
原因:按索引对齐后取并集,缺失侧填 NaN;DataFrame 与 Series 运算默认按列名对齐,方向和直觉相反。
解决:运算前先reindex到同一索引,或用add/sub并传fill_value;DataFrame 与 Series 运算时显式写axis,别依赖默认值。
5.4 groupby 之后形状不是预期的
现象:groupby(...).agg(...)拿到的是多级列索引,取列时报 KeyError;或者groupby(...).apply(...)的结果多出一层索引,赋回原表全变 NaN。
原因:agg传函数列表会产生「列名 + 函数名」的多级列;apply与groupby组合时组键会保留在索引里;rolling叠加groupby时还会再加一层原索引。
解决:优先用命名聚合把输出列名定死;拿到结果后先print(result.index)和print(result.columns)看清结构,再决定reset_index哪一层、droplevel哪一层。
5.5 dtype 悄悄变了
现象:读进来的整数列做了一次含缺失值的运算,变成 float;本该是数字的列变成 object,排序结果按字符串字典序而不是数值大小。
原因:Pandas 的整数类型没有 NaN 表示,一旦引入缺失值只能升格为 float;read_csv遇到无法解析的值会把整列降级为 object,而且不给警告。
解决:读取时用dtype=显式声明关键列类型,用na_values=指定哪些字符串算缺失;拿到新数据先跑一遍df.dtypes和df.info(),把类型问题挡在入口。
6. 内存取舍:object 列和隐式复制,才是性能账本上的大头
把一份几百万行的表塞进内存,第一反应往往是换更快的机器,但多数时候瓶颈不在硬件,而在两处设计上的取舍。
第一处是 object 类型。字符串列在 Pandas 里默认存成 Python 对象指针数组,每个元素都是一次独立分配,内存占用和访问开销都远高于数值列。Categorical的解法是把字符串映射成整数编码,只存编码和一张字典表,重复度越高收益越大。代价是失去部分灵活性:类别集合要显式维护,新增未登记的取值会变成 NaN,字符串方法也不能随便用。低基数的分组键、枚举型状态列适合转,自由文本列不适合——这就是取舍本身。
第二处是复制。copy-on-write的意义是把「什么时候真的复制」推迟到写入那一刻,读操作共享底层块,语义上更接近「视图」。它让内存曲线好看,但也意味着「我改了切片,原表会不会跟着变」这个问题永远需要显式确认。我自己的习惯是不赌默认行为,该.copy()的地方直接写出来。
至于chunksize,它换的是「全局视图」:分块读能把峰值内存压到常数级,但任何需要全表统计的操作(去重、全局排序、分位数)都得自己写累积逻辑,复杂度从一行代码变成十几行状态维护。只有当数据确实超过内存时,这笔账才划算。
从那以后我每次接手一份新数据,都强制先跑一遍df.info()和df.memory_usage(deep=True),看清类型分布和真实占用,再决定是转Categorical、分块读,还是老老实实加内存。这份按四层难度排布的 Pandas 题库,适合当成一份查漏清单:先做题,再回到索引、分组、连接、类型这四条主线上对照,哪一块答得含糊,就去翻对应的机制。希望帮到你。
本文还有配套的精品资源,点击获取