1. 为什么DataFrame进阶用法值得单独拎出来讲
很多人学Pandas的路径都差不多:先看几个教程,学会了read_csv读数据、df.head()看前几行、df['列名']取一列、df[df['分数']>90]做个筛选,然后就觉得自己“会用Pandas”了。可真拿到一份稍微像样的数据集——几十个字段、上万行、夹杂着缺失值和重复记录、需要按多个维度分组聚合再和另一张表拼接——立刻就卡住了。这不是基础没打好,而是从“能读能筛”到“能洗能算能拼”之间,隔着一层窗户纸,这层窗户纸就是DataFrame的进阶用法。
我带的几个做数据分析的实习生,几乎每个人都在这个阶段卡过。基础篇教的是“单个动作”,进阶篇要解决的是“组合动作”:怎么把多个条件串起来筛选、怎么按组算完指标再拼回原表、怎么处理那些看起来一样但实际有细微差别的重复行、怎么在不动原始数据的前提下派生新字段。这些操作在真实项目里出现的频率远高于单纯的行列选取,但很多入门材料一笔带过,导致大家到了实战环节只能靠搜索引擎拼凑零散知识。
这篇内容面向的是已经能跑通基础DataFrame操作、准备进入真实数据清洗与特征工程阶段的读者。不管你是做机器学习前置的数据准备,还是日常业务报表的加工,下面这些用法都会反复用到。我会把每个操作背后的“为什么”讲清楚,把参数选择的逻辑拆开,再配上可以直接抄的代码和踩过的坑。机器学习前置技术栈里,DataFrame进阶用法是绕不开的一环——特征工程八成以上的操作都落在这几个API上。
2. 条件筛选的进阶:从单条件到多条件组合
2.1 布尔索引的本质与括号陷阱
基础篇里大家学过df[df['年龄'] > 30],这行代码能跑通,但很多人没想过它为什么能跑通。方括号里那个表达式返回的是一个布尔Series,长度和行数一致,Pandas拿这个布尔序列去对齐行索引,为True的行留下,为False的行丢掉。理解这一点,后面所有复杂筛选都是同一个逻辑的延伸。
多条件组合时,运算符的选择是第一个坑。Python的and、or、not在Pandas里不能直接用,因为它们是标量逻辑运算符,而我们要的是逐元素的向量化运算。正确写法是用&、|、~,并且每个条件必须用括号包起来:
# 正确写法 result = df[(df['年龄'] > 30) & (df['城市'] == '北京')] # 错误写法一:用and result = df[df['年龄'] > 30 and df['城市'] == '北京'] # 报错 # 错误写法二:漏括号 result = df[df['年龄'] > 30 & df['城市'] == '北京'] # 优先级错乱,结果不对漏括号这个问题特别隐蔽,因为有时候它不报错,只是结果莫名其妙。原因是&的优先级高于>,30 & df['城市']会先被计算,产生意料之外的结果。我自己的习惯是:只要条件超过一个,每个条件无条件加括号,不给自己留犯错空间。
2.2 isin、between与query的适用场景
条件多了以后,代码可读性会急剧下降。Pandas提供了几个工具来缓解这个问题,各有各的适用边界。
isin适合“某列的值属于一个集合”的场景。比如筛选出几个指定城市的记录:
cities = ['北京', '上海', '广州'] result = df[df['城市'].isin(cities)]比写三个==再用|连起来清爽得多。反过来,~df['城市'].isin(cities)就是“不属于”的意思。
between适合连续区间,注意它默认是闭区间,两端都包含:
result = df[df['年龄'].between(25, 35)] # 包含25和35 result = df[df['年龄'].between(25, 35, inclusive='left')] # 只含左端query则是把条件写成字符串表达式,适合条件特别长、或者列名带空格等特殊字符的情况:
result = df.query("年龄 > 30 and 城市 == '北京'")query里可以用and、or,因为它是Pandas自己解析的表达式,不走Python的运算符。但要注意列名如果和Python关键字冲突,需要用反引号包起来。我一般只在条件特别复杂、或者需要动态拼接条件字符串时才用query,日常筛选还是布尔索引更直观,调试也方便。
2.3 用loc做条件筛选并同时选列
一个常见需求是:筛出满足条件的行,但只保留其中几列。新手容易写成两步:
temp = df[df['分数'] > 80] result = temp[['姓名', '分数']]这样写没错,但多了一步中间变量。更紧凑的写法是用loc,行条件在前、列选择在后:
result = df.loc[df['分数'] > 80, ['姓名', '分数']]loc的完整签名是df.loc[行条件, 列条件],两个位置都可以放布尔序列或列名列表。这个写法在链式操作里特别有用,因为它一步到位,不会产生SettingWithCopyWarning。说到这个警告,下一节专门讲。
3. 赋值与修改:SettingWithCopyWarning的来龙去脉
3.1 这个警告到底在警告什么
几乎每个Pandas用户都被SettingWithCopyWarning骚扰过。它的核心含义是:你可能在一个临时切片上做修改,而原始DataFrame不会跟着变,导致你以为改了、其实没改。Pandas没法确定你的意图,所以用警告提醒你检查。
触发场景通常是链式索引赋值:
# 可能触发警告 df[df['分数'] > 80]['等级'] = '优秀'这行代码先筛出一个子集(可能是视图也可能是副本,Pandas不保证),然后在子集上赋值。如果子集是副本,赋值只改了副本,原df纹丝不动。正确做法是用loc一步完成:
df.loc[df['分数'] > 80, '等级'] = '优秀'3.2 视图与副本的判断逻辑
Pandas底层基于NumPy,切片操作有时返回视图(共享内存)、有时返回副本(独立内存),规则并不总是直观。简单记:用[]做列选择通常返回视图,用布尔索引筛选通常返回副本,但这不是铁律。与其去记规则,不如养成习惯——凡是“筛选后赋值”,一律用loc;凡是需要独立修改的,显式加.copy()。
subset = df[df['分数'] > 80].copy() subset['等级'] = '优秀' # 明确知道改的是副本,不碰原表.copy()会带来内存开销,大数据集上要留意。如果只是读取不修改,不需要copy;只有确定要改子集且不希望影响原表时,才值得付出这份开销。
3.3 批量赋值的几种高效写法
给整列赋同一个值,直接df['新列'] = 0即可。按条件赋不同值,除了loc,还可以用np.where或np.select:
import numpy as np # 二选一 df['等级'] = np.where(df['分数'] >= 60, '及格', '不及格') # 多选一 conditions = [df['分数'] >= 90, df['分数'] >= 60, df['分数'] < 60] choices = ['优秀', '及格', '不及格'] df['等级'] = np.select(conditions, choices, default='未知')np.select按顺序匹配,第一个为True的条件生效,所以条件顺序要从严格到宽松排。这个细节很多人忽略,导致“90分以上”被“60分以上”抢先匹配。数据量大时,np.select比逐条件loc赋值快不少,因为它是一次向量化计算。
4. 分组聚合:groupby的进阶玩法
4.1 分组-聚合-合并的三段式心智模型
groupby是DataFrame进阶里最核心的API,没有之一。它的心智模型可以概括为三步:split(按key拆成若干组)→ apply(每组算一个聚合结果)→ combine(把结果拼回一个表)。理解这三步,各种变体用法都能推导出来。
最简单的形式:
df.groupby('城市')['销售额'].sum()这行代码按城市分组,对销售额求和,返回一个以城市为索引的Series。如果要多列多指标:
df.groupby('城市').agg({ '销售额': 'sum', '订单量': 'count', '客单价': 'mean' })agg接受字典,键是列名、值是聚合函数名或函数列表。同一个列要多个指标时,值传列表:
df.groupby('城市').agg({'销售额': ['sum', 'mean', 'max']})这样会产生多级列索引,后续取用需要用元组,比如result[('销售额', 'sum')]。嫌麻烦可以在agg后加.droplevel(0, axis=1)或者重命名。
4.2 自定义聚合函数与命名聚合
内置函数不够用时,可以传自定义函数:
def range_func(x): return x.max() - x.min() df.groupby('城市')['销售额'].agg(range_func)但自定义函数走的是Python循环,大数据集上慢。能用内置向量化函数就别自己写。Pandas 0.25之后引入了命名聚合(named aggregation),可以一步搞定“聚合+重命名”,避免多级索引的麻烦:
df.groupby('城市').agg( 总销售额=('销售额', 'sum'), 平均客单价=('客单价', 'mean'), 订单数=('订单号', 'count') )关键字就是结果列名,值是(原列名, 聚合函数)元组。这个写法我强烈推荐,代码自解释,结果列名干净,省去后续重命名步骤。
4.3 transform:把组内统计量拼回原表
agg返回的是每组一行,行数变少了。但很多场景需要“组内统计量作为新列拼回原表”,比如算每个城市的平均销售额,然后给每条记录标注“该城市平均销售额”。这时候用transform:
df['城市平均销售额'] = df.groupby('城市')['销售额'].transform('mean')transform返回的Series和原表行数一致、索引对齐,可以直接赋值成新列。这是特征工程里极高频的操作——比如“用户消费金额减去该用户所在城市的平均消费”这类特征,一行transform就出来了。
transform也支持自定义函数,但同样要注意性能。另外transform和agg的区别要记牢:agg降维,transform保维。选错了要么行数对不上,要么赋值报错。
4.4 filter:按组筛选整组保留或丢弃
还有一种需求是“只保留满足条件的组”,比如“只保留订单数超过100的城市的所有记录”。这不是筛行,是筛组,用filter:
df.groupby('城市').filter(lambda g: len(g) > 100)filter的函数接收每个组的子DataFrame,返回True则整组保留。注意它返回的是原表结构(所有列都在),只是行被过滤了。这个操作在数据清洗阶段很有用,比如剔除样本量过少的组,避免后续统计不可靠。
5. 表连接与重塑:merge、concat、pivot
5.1 merge的四种连接方式与键的选择
单表操作玩熟了,真实项目里八成要面对多表关联。merge是主力工具,核心参数是how和on:
| how取值 | 含义 | 结果行数 |
|---|---|---|
| inner | 交集,两边都有的键才保留 | 最少 |
| left | 左表全保留,右表匹配不上的填NaN | 等于左表 |
| right | 右表全保留 | 等于右表 |
| outer | 并集,两边都保留 | 最多 |
result = pd.merge(orders, users, on='用户ID', how='left')键的选择有个坑:如果两表键名不同,用left_on和right_on分别指定;如果键有重复值,会产生笛卡尔积式的行数膨胀,merge前务必确认键的唯一性。我习惯在merge前先df['键'].duplicated().sum()看一眼,非零就得想清楚是数据问题还是业务上本就一对多。
merge后行数对不上是新手最常见的困惑。排查顺序:先看两表键是否有重复,再看how选得对不对,最后看键的数据类型是否一致(比如一边是int一边是str,匹配不上但不报错)。数据类型不一致这个问题特别隐蔽,建议merge前统一用astype转一下。
5.2 concat的轴向与索引处理
concat用于拼接,axis=0是纵向堆叠(行增加),axis=1是横向拼接(列增加)。纵向拼接时索引会重复,加ignore_index=True重新生成:
result = pd.concat([df1, df2], axis=0, ignore_index=True)横向拼接按索引对齐,索引对不上的位置填NaN。如果两表行数相同但索引不同,结果会出乎意料,所以横向拼接前最好先reset_index。
5.3 pivot_table:从长表到宽表的转换
做报表或特征矩阵时经常需要把长表转宽表。pivot_table是透视表工具:
pivot = df.pivot_table( index='城市', columns='月份', values='销售额', aggfunc='sum', fill_value=0 )index是行维度,columns是列维度,values是填充值,aggfunc决定同一单元格有多个值时怎么聚合。fill_value=0把空单元格填0,避免后续计算出NaN。透视表在特征工程里常用于构造“用户×品类”的消费矩阵,转完再拉平就是特征。
6. 缺失值与重复值:清洗环节的硬功夫
6.1 缺失值的识别与统计
isnull()返回同形状的布尔表,sum()按列统计缺失数:
df.isnull().sum() df.isnull().mean() # 缺失比例,比绝对数更有参考价值缺失比例超过一定阈值(比如50%)的列,要考虑是否直接丢弃。阈值没有统一标准,看业务含义——关键字段哪怕缺10%也得想办法补,边缘字段缺一半可能直接删。
6.2 填充策略的选择逻辑
fillna的填充值选择是门学问。均值填充简单但会压缩方差、扭曲分布;中位数填充对异常值稳健;前向填充(ffill)适合时间序列;分组均值填充(结合transform)更精细:
df['销售额'] = df.groupby('城市')['销售额'].transform( lambda x: x.fillna(x.mean()) )这行代码按城市分组,用组内均值填充组内缺失。比全局均值填充合理得多,因为不同城市的销售额量级可能差很远。填充策略没有标准答案,核心原则是:填充后的数据分布要尽量接近真实分布,且不能引入未来信息(时间序列场景尤其注意)。
6.3 重复值的判定与去重
duplicated()标记重复行,默认所有列都相同才算重复。可以指定子集:
df.duplicated(subset=['用户ID', '日期']).sum() df.drop_duplicates(subset=['用户ID', '日期'], keep='last')keep参数决定保留哪一条:'first'保留第一条,'last'保留最后一条,False把所有重复的都删掉。业务上“保留最新一条”很常见,这时候通常需要先按时间排序再keep='last'。去重前一定要想清楚“重复”的业务定义——是整行重复,还是某个业务主键重复?定义错了,去重就是灾难。
7. 常见问题与排查速查
7.1 高频报错与对应解法
| 现象 | 可能原因 | 解法 |
|---|---|---|
| SettingWithCopyWarning | 链式索引赋值 | 改用loc一步赋值,或显式copy |
| merge后行数暴增 | 键有重复值 | 检查键唯一性,确认连接逻辑 |
| 筛选结果为空但数据明明有 | 数据类型不一致或空格 | 检查dtype,字符串strip |
| groupby结果列名多级 | agg传了列表 | 用命名聚合或droplevel |
| transform赋值报索引错 | 索引未对齐 | reset_index或确认分组键 |
7.2 几个踩过的坑
第一个坑:字符串列里混了空格,== '北京'匹配不上'北京 '。清洗时统一str.strip(),或者筛选前先看一眼唯一值。
第二个坑:数值列被读成了object类型,因为混了非数字字符。pd.to_numeric(errors='coerce')可以强制转换,转不了的变NaN,再决定怎么处理。
第三个坑:groupby默认dropna=True,分组键为NaN的组会被静默丢弃。如果NaN本身是一个有意义的组,要显式dropna=False。
第四个坑:链式操作里inplace=True和赋值混用,导致后续拿不到返回值。我的建议是尽量不用inplace,统一用赋值,代码更可预测。
7.3 性能上的实操心得
数据量上了百万行,几个习惯能省不少时间:筛选时先选列再筛行(减少中间数据量);groupby前把分组键设为索引或转成category类型;避免在循环里逐行操作,能用向量化就用向量化;apply能不用就不用,它比内置聚合慢一个量级。这些不是玄学,都是实测出来的差距,数据越大越明显。
8. 把这些用法串起来:一个完整的特征加工流程
光看单个API容易忘,串成一个流程才记得住。假设有一份订单表和一份用户表,目标是构造“用户级特征表”喂给模型。流程大致是:读入两表→清洗缺失和重复→订单表按用户分组聚合出消费统计→用transform派生“单笔金额占用户均值比例”→merge用户表补上画像字段→pivot出品类偏好矩阵→拉平→最终得到一行一用户的特征表。
这个流程里,第2节的条件筛选用于剔除异常订单,第3节的loc赋值用于打标签,第4节的agg和transform用于构造统计特征,第5节的merge和pivot用于整合多源信息,第6节的清洗贯穿始终。每个环节单独看都不复杂,组合起来就是机器学习前置技术栈里最实在的那部分工作。我个人的体会是,这些API的语法查文档十分钟能记住,但“什么时候该用哪个、参数怎么选、结果不对怎么排查”这套判断力,只能靠反复在真实数据上练。练的时候刻意记录每次踩坑的原因,比刷十篇教程管用。