做数据分析这一行,天天跟表格打交道。不管你是用Excel手动撸,还是用Python写脚本处理,只要数据量稍微上来一点,最常干的一件事就是:把需要的数据挑出来,把不需要的数据扔掉。这活儿听起来简单,但真要做得干净利落、不踩坑,里头门道不少。今天我就以Pandas里的DataFrame为例,把“条件筛选”这件事从头到尾捋一遍。这不仅是数据清洗的基石,也是你从“会用Pandas”迈向“用得溜”的必经之路。这篇文章适合刚接触Pandas的初学者,也适合已经写了不少脚本但总感觉哪里别扭的进阶选手——看完你可能会发现,原来很多费劲的写法,其实有更优雅的解法。
很多人觉得条件筛选不就是df[df['列名'] > 10]嘛,有什么好讲的。但实际上,据我观察,不少人在真实项目里处理复杂条件时,要么写了一长串让人头皮发麻的&和|,要么因为没搞懂布尔索引的原理,结果被各种奇怪的报错逼到改用循环硬扛。这篇文章不会只给几个API示例就完事,我会把背后的判断逻辑、常见的坑、以及我这几年代码生涯里总结出来的实操套路全部交代清楚,保证你看完能直接用在自己的数据清洗流程里。
1. 条件筛选的本质:一张布尔面具(Boolean Mask)
1.1 从一次Excel手工筛选说起
先讲个我自己的经历。早几年我做电商运营数据分析,那时候还不怎么写代码,每天在Excel里做同一件事:把退货率高于10%、同时又属于高单价品类的订单挑出来重点分析。每次操作都是“筛选→复制→粘贴→再筛选”,一上午就耗进去了。后来我学Python,第一次在Jupyter里敲下df[(df['return_rate'] > 0.1) & (df['category'] == 'high_price')],看到结果的那一瞬间,真的有种“人生被点亮”的感觉——几秒钟,几千行数据就挑完了。而且更棒的是,整个过程是“可复现”的,下次数据更新了,重新跑一遍脚本就行,不用再手动点鼠标。
这个例子想说明什么?条件筛选的本质,不是“查数据”,而是“用一个规则去匹配每一行,留下匹配的,拿走不匹配的”。在Pandas里,这个规则的表现形式就是一个由True和False组成的序列,我们管它叫布尔索引(Boolean Mask),也有人叫布尔面具。你可以把它想象成一张带孔的纸,盖在数据表上,孔的位置就是符合条件的行,没孔的位置就被遮住了。
1.2 布尔索引:为什么True和False能筛选数据
理解了“面具”这个概念,你就迈过了Pandas筛选的第一道门槛。具体来说,当你写df['return_rate'] > 0.1的时候,Pandas不是简单告诉你“有没有大于0.1”,而是返回一个长度和DataFrame行数一致的布尔Series。比如:
python复制import pandas as pd
import numpy as np
df = pd.DataFrame({
'order_id': range(1, 6),
'return_rate': [0.05, 0.12, 0.08, 0.15, 0.03]
})
mask = df['return_rate'] > 0.1
print(mask)
输出长这样:
text复制0 False
1 True
2 False
3 True
4 False
Name: return_rate, dtype: bool
注意,这个结果是和你原始DataFrame的行索引一一对应的。第0行是False,第1行是True……这时候再执行df[mask],Pandas会遍历这个布尔序列,凡是True的索引就保留,False的就丢掉。这就是筛选的全部秘密——没有什么魔法,就是一个“对号入座”的过程。
我见过不少新手犯迷糊,把df[df['col'] > 10]误解成“把col列大于10的值取出来”,其实不是,它是“把col列大于10的那些行整行取出来”。这个区别非常重要:筛选的目标永远是“行”,条件只是我们用来判断每行去留的依据。
1.3 比较操作符:你得先能判断大小
要生成布尔Mask,第一步得会做判断。Pandas支持Python里所有常见的比较操作符,直接用就行:
>大于>=大于等于<小于<=小于等于==等于(注意是两个等号,一个等号是赋值)!=不等于
这些操作符作用于Series或DataFrame时,返回的都是逐元素的布尔结果。配合数值列、字符串列都能用。例如按产品ID筛选:df[df['product_id'] == 'A1001'];按时间段筛选:df[df['order_date'] >= '2024-01-01']。
这里有个小细节值得说——==用于字符串匹配时,是严格区分大小写的。'apple'和'Apple'在Pandas看来是两个完全不同的值。如果你不在意大小写,需要用到后面的str.contains(..., case=False)方法,这个我们先留个悬念,后面实战部分会聊到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从单条件到多条件:组合筛选的三种思路
2.1 单条件筛选,先跑通一个最简单例子
最简单的场景就是筛选某一列满足某个条件的所有行。假设你手上有一份员工工资表,想看看月薪过万的人:
python复制employees = pd.DataFrame({
'name': ['张三', '李四', '王五', '赵六'],
'salary': [8500, 12000, 15000, 9800],
'department': ['技术部', '市场部', '技术部', '行政部']
})
high_salary = employees[employees['salary'] > 10000]
print(high_salary)
结果会留下张三和李四(这里李四、王五),其他全部过滤掉。这个操作本身很简单,但注意它返回的是一个新的DataFrame对象,并不会修改原始数据。这一点很重要:很多新手以为执行完之后原来的employees也会变,结果发现一直“没生效”,其实就是没认识到Pandas多数操作默认是非原地修改的。想要保存结果,得显式赋值给一个新变量,比如我上面写的high_salary。
2.2 用&、|、~组合多重条件,括号千万别省
真实业务里很少只用一个条件。你经常需要同时满足多个条件、满足任意一个条件、或者对某条件取反。这时候就用到三个逻辑操作符:
&:按位与,表示“并且”|:按位或,表示“或者”~:按位非,表示“取反”
用法如下:
python复制# 技术部且工资大于10000
tech_high = employees[(employees['department'] == '技术部') & (employees['salary'] > 10000)]
# 市场部或行政部
mk_admin = employees[(employees['department'] == '市场部') | (employees['department'] == '行政部')]
# 非技术部
not_tech = employees[~(employees['department'] == '技术部')]
如果你照着这个代码敲,发现一切正常。但如果你图省事,把括号去掉写成employees[employees['department'] == '技术部' & employees['salary'] > 10000],大概率会直接报错,或者得出一个莫名其妙的结果。原因在于Python运算优先级里,==的优先级比&要高,导致解释器把表达式解析成了employees['department'] == ('技术部' & employees['salary']) > 10000——这显然是错的。所以记住一句话:每个条件判断都要用括号包起来,再跟&、|连接。这不是风格问题,是语法要求。
还有一点:很多人初学Pandas时会把Python的and、or拿到DataFrame里用,结果也会报错:“The truth value of a Series is ambiguous”。因为and和or是Python内建逻辑运算符,它要求两边的值能明确转成布尔值,而一个Series里有True也有False,到底该按哪个算?没法算。所以Pandas规定不能用and/or,必须用位运算符&/|,这是新手最容易踩的坑之一。
2.3 用query()让长条件秒变清爽
当筛选条件特别多、特别长时,一长串&和|容易把自己绕晕。有一个替代方案是Pandas自带的query()方法,它接受字符串形式的查询表达式,语法更接近自然语言。比如上面那个技术部高薪的例子可以写成:
python复制tech_high = employees.query("department == '技术部' and salary > 10000")
注意,query()里用的是and、or、not这些关键词,而不是&、|、~。而且对列名的引用不需要重复写employees['xxx'],直接写列名就行,文本量立刻就减少了。如果有变量要传进去,还可以用@符号引用外部变量:
python复制threshold = 10000
filtered = employees.query("salary > @threshold")
这个写法我真的强烈推荐,尤其是在Notebook里临时做探索性分析的时候,条理清楚,而且不容易漏括号。不过query()也有它不擅长的地方——比如遇到列名带空格或者特殊字符时,需要用反引号包裹列名,稍微麻烦一点。另外,如果你要用到一些复杂的字符串方法(比如str.contains),query()支持起来就比较费劲,这时候还是得回到方括号语法。
3. 进阶筛选:字符串、时间、空值一个都不能少
3.1 字符串筛选:.str.contains()和.str.startswith()
数据清洗时,字符串列筛选是家常便饭。比如你有一列订单号,要看哪些订单属于华东大区,规则是订单号以HD开头:
python复制orders = pd.DataFrame({
'order_no': ['HD001', 'BD002', 'HD003', 'XB004'],
'amount': [100, 200, 300, 400]
})
hd_orders = orders[orders['order_no'].str.startswith('HD')]
print(hd_orders)
如果要对字符串进行模糊匹配——比如商品名称里包含“手机”的订单:
python复制phones = orders[orders['order_no'].str.contains('HD', na=False)]
这里我特意加了na=False参数,这个细节很关键。当你的列里有缺失值(NaN)时,.str.contains()默认会返回NaN而不是False,NaN在布尔上下文里会被当成True,导致筛选结果里混进一些不该出现的行。这算是一个隐藏比较深的坑,不踩一次很难记住。
另外,.str.contains()做的是子串匹配,不是正则表达式匹配,不过它其实支持正则(默认regex=True)。如果你只想做字面意义上的包含,最好加个regex=False参数,避免商品名里的特殊字符被当成正则语法解析,影响查询速度还容易报错。
3.2 时间筛选:先转换类型,再比较大小
时间列的筛选,核心就一条:先把字符串转换成datetime类型,再进行比较或切片。举个例子,原始数据里的日期是'2024-03-15'这种字符串,你直接df[df['date'] > '2024-03-01']也能比较,因为字符串按字典序排序时这个格式刚好和日期顺序一致。但万一格式变成'2024/03/15'或者'15/03/2024',字符串排序就全乱了,这时候必须先做类型转换:
python复制df['date'] = pd.to_datetime(df['date'])
# 筛选3月1日之后的数据
march_after = df[df['date'] >= '2024-03-01']
# 筛选某个时间段
between_dates = df[df['date'].between('2024-03-01', '2024-03-31')]
pd.to_datetime()自动解析字符串的能力很强,大多数常见日期格式都能识别。如果遇到变态格式(比如带中文“2024年3月15日”),可以用format参数指定解析规则,例如pd.to_datetime(df['date'], format='%Y年%m月%d日')。筛选时间区间时,.between()方法相比>=和<=组合写起来更简洁,而且包含边界值,相当于闭区间。
3.3 空值处理:isna()和notna()的正确打开方式
数据清洗里,处理缺失值可能是最频繁的操作之一。你要么把空值行挑出来看看原因,要么把空值行直接丢掉。Pandas提供了两个方法:
python复制# 找出所有收入为空的用户
missing_income = df[df['income'].isna()]
# 删除所有含空值的行
df_dropna = df.dropna()
# 删除指定列中带空值的行
df_dropna_sub = df.dropna(subset=['income', 'age'])
这里要特别提醒:isna()和isnull()功能完全一样,混用完全没问题。dropna()默认只要一行里任何一个字段是空值,整行都会被删掉,这在某些场景下过于激进。比如你只想根据“收入”这个字段删行,就必须用subset=['income']限定检查范围。另外,dropna()也是非原地操作的,要保存结果就得赋值。
判断完空值,很多时候你还需要把空值填上默认值,这时候fillna()就上场了:
python复制df['income'] = df['income'].fillna(0)
df['category'] = df['category'].fillna('unknown')
注意,fillna('unknown')往字符串列填补是安全的,但如果你乱给数值列填'unknown',这列就会被Pandas转成object类型,后续所有数值运算就全废了。填之前最好先看一眼列的类型。
3.4 用isin()和between()快速搞定“在范围内”的判断
除了==和>这些基础比较,isin()和between()是两种非常常用的筛选利器。
isin()用于判断某列的值是否在一个集合中,比写一长串|条件简洁得多:
python复制# 想筛出北京、上海、广州三个城市的订单
target_cities = ['北京', '上海', '广州']
city_orders = df[df['city'].isin(target_cities)]
这条语句等价于df[(df['city']=='北京') | (df['city']=='上海') | (df['city']=='广州')],但代码量少一半以上,可读性也强。
between()则用于判断数值或时间是否落在某个区间内,而且默认包含两端边界:
python复制# 年龄在18到30岁之间
young_users = df[df['age'].between(18, 30)]
# 金额在100到500之间(含100和500)
mid_amount = df[df['amount'].between(100, 500, inclusive='both')]
Pandas的版本更新后,between()的inclusive参数有几种选项:'both'(两端都含)、'left'(含左不含右)、'right'(含右不含左)、'neither'(两端都不含)。默认值是'both'。使用前先确认你的需求到底是闭区间还是开区间,否则边界值容易被误判。
4. 实战演练:一份订单表的清洗与筛选全流程
4.1 场景设定与数据准备
理论和技巧聊了不少,我们把它放进一个完整的例子里串一遍。假设你是一家电商公司的数据分析师,某天早上收到运营同事发来的一份订单明细表orders.csv,文件有1万多行。你需要完成以下清洗任务:
- 删除重复的订单号;
- 过滤掉订单金额小于等于0的异常记录;
- 剔除订单状态为“已取消”的订单;
- 只保留最近30天内的有效订单;
- 最后,把结果按城市和订单金额汇总,看看各城市的销售额排名。
先构造一份模拟数据,方便演示:
python复制import pandas as pd
import numpy as np
np.random.seed(42)
data = {
'order_id': np.random.choice(['A1001', 'A1002', 'A1003', 'A1004'], size=100),
'city': np.random.choice(['北京', '上海', '广州', '深圳'], size=100),
'amount': np.random.uniform(-10, 500, size=100),
'status': np.random.choice(['已完成', '已取消', '待支付'], size=100),
'order_date': pd.date_range('2024-01-01', periods=100, freq='D')
}
df = pd.DataFrame(data)
注意,我故意让order_id里有重复值(用choice随机抽),订单金额也包含负数,就是为了模拟真实数据里的“脏”情况。
4.2 清洗第一步:用drop_duplicates()干掉重复行
先处理重复订单号。drop_duplicates()的默认行为是保留第一次出现的行,后续重复行全部删除。指定subset='order_id',只根据订单号判断重复:
python复制df = df.drop_duplicates(subset='order_id', keep='first')
这里有几个细节值得展开说。第一个是keep参数,除了'first'(保留第一条)和'last'(保留最后一条)之外,还可以设为False,表示把重复的行全部删掉,一行都不留。如果你要做去重后检查,用df.duplicated(subset='order_id').sum()可以统计重复行数。第二个是去重之后,索引会保留原来的标签,比如原本是第3行、第7行被留下了,索引还是3和7,这可能会影响后续代码。建议在清洗流程的早期或者结束后,加一句df = df.reset_index(drop=True),把索引重置成0到N-1的连续整数,省得后面loc定位时被索引弄晕。
4.3 清洗第二步:过滤异常值和无效状态
接着过滤金额异常和无效状态。这里涉及多条件组合,正好复习一下&和括号的用法:
python复制# 删除金额小于等于0的异常单
df = df[df['amount'] > 0]
# 删除状态为“已取消”的订单
df = df[df['status'] != '已取消']
# 也可以合并成一步
df = df[(df['amount'] > 0) & (df['status'] != '已取消')]
三种写法等价,我个人喜欢最后一种,一步到位,而且逻辑集中。但有一种场景要注意:当你需要保留“非取消”订单时,!=是最直观的写法。如果你习惯用~取反也可以:~df['status'].eq('已取消')。两者效果一样,看个人喜好。
4.4 清洗第三步:时间窗口筛选
接下来按需求4,保留最近30天的数据。假设“今天”是2024-04-10,那起始日期就是2024-03-11:
python复制start_date = '2024-03-11'
recent_orders = df[df['order_date'] >= start_date]
如果要更严谨一点,把order_date转换为datetime类型再做比较。实际上在这个例子里,order_date已经是用pd.date_range()生成的datetime列了,可以直接比较。如果是外部读进来的CSV,保险起见还是先执行一次pd.to_datetime()。
4.5 聚合收尾:清洗后数据怎么用
清洗完成的最后一步,通常是对数据进行汇总分析。这里以“按城市统计销售额”为例:
python复制city_sales = recent_orders.groupby('city')['amount'].sum().sort_values(ascending=False)
print(city_sales)
输出大概长这样:
text复制city
上海 24723.55
深圳 23888.20
北京 22110.30
广州 21005.85
Name: amount, dtype: float64
如果你还想看每个城市的订单量,可以用agg()一次性算出多个指标:
python复制city_stats = recent_orders.groupby('city')['amount'].agg(['sum', 'count', 'mean'])
print(city_stats)
这段代码跑完,你交付给运营同事的就是一张干净、可直接透视的汇总表。整个清洗过程写成一个Python脚本后,后续每周更新数据时,只需要重新读入文件,执行一遍,结果自动刷新——这也是用Pandas做条件筛选和清洗的核心价值所在:让重复劳动自动化。
4.6 顺手查看清洗前后的数据规模
一个非常实用的习惯是,在清洗每个步骤后都打印一下当前行数,观察数据量的变化。这样你能清楚地知道每一步筛掉了多少行,也能在结果异常时快速定位是哪一步出了问题。比如:
python复制print('原始行数:', len(df_raw))
print('去重后行数:', len(df_no_dup))
print('删除异常金额后行数:', len(df_valid_amount))
print('删除取消状态后行数:', len(df_valid_status))
print('最近30天行数:', len(df_recent))
这种日志习惯,在数据量大的时候尤其重要。数据清洗本来就是一个不断“损耗”的过程,如果某一步把你99%的数据全删了,你起码能快速发现是哪个条件写错了,而不是等汇总结果出来才觉得不对劲。
5. 常见问题与排查技巧实录
5.1 为什么我用&连接条件时报错
这个问题几乎每个Pandas学习者都会遇到。出错信息通常是TypeError: unsupported operand type(s) for &: 'str' and 'bool',或者ValueError: The truth value of a Series is ambiguous。
第一种情况基本就是漏了括号。每个条件都要用圆括号包住,确保&作用在布尔Series上,而不是直接作用在某个字符串上。第二种情况则是用了and/or,还记得前面说的吗,and要求两边能转成单个布尔值,而Series做不到,所以Pandas干脆报错提醒你。
排查方法很简单:把表达式的每部分拆开打印看看,print(df['column'] > 10)输出正常吗?print(type(condition))是不是Series类型?确认每个条件都是Series,再合起来看整体类型。这种从局部到整体的排查思路,能帮你少走很多弯路。
5.2 筛选结果丢失了索引,怎么办
筛选后得到的新DataFrame,索引保留的是原表的索引标签,可能出现0、2、5、9这样“没头没尾”的样子。本身这不影响数据操作,但如果你用loc按索引取数,或者要把两个DataFrame合并,索引不一致就会带来很多麻烦。
解决方法就是前面提到的reset_index(drop=True)。drop=True的意思是扔掉旧索引,直接用新的整数索引。不带这个参数的话,旧索引会变成一列,名字叫index,有时候反而碍事。
5.3 为什么筛选后出现了一堆NaN行
这个情况我见过不少次。最常见的场景是:某列有缺失值,你用df[df['col'] == '某个值']去筛选,结果发现结果里出现了一些col显示为NaN的行。
原因比较复杂,简单说就是当列里有NaN时,col != '某个值'对NaN会返回True(因为NaN不等于任何东西,所以“不等于”这个判断对它来说是成立的)。这就导致取反筛选时,NaN行反而被留下了。要精确排除NaN,必须显式加条件:df[(df['col'] == '某个值') | (df['col'].isna())]或者反过来df[df['col'].notna() & (df['col'] == '某个值')]。如果用了.str.contains(),加na=False参数也能规避这个问题。
所以当你的筛选结果里莫名其妙多出几行“不匹配”的数据,先检查原始列里是不是有NaN。数据清洗时,对空值的处理必须排在各种条件判断之前,否则筛选逻辑会变得异常混乱。
5.4 速度太慢,几百万行筛选卡死怎么办
大数据量下,df[df['col'].apply(lambda x: ...)]这种写法很容易成为性能瓶颈。因为apply本质上是Python循环,逐行调用函数,效率极低。Pandas的向量化操作底层是C语言实现,快到飞起,能用内置方法解决的就别用apply。
比如说,要判断一列字符串是否包含某些关键词,别写df['desc'].apply(lambda x: '手机' in x),直接写df['desc'].str.contains('手机', na=False)。要按多个条件筛选,直接组合布尔Series,不要循环逐行判断。如果真的遇到内置方法解决不了的自定义逻辑,也尽量用np.where配合向量化操作实现,而不是陷入纯Python循环。数据量一旦达到百万级,这个性能差距可以到几十倍甚至上百倍。
还有一个不常被提及的点:Pandas筛选时会复制数据(copy),多次筛选后内存会被实际占用。如果数据特别大,建议在筛选前先df = df.copy()明确复制一份,避免后面出现SettingWithCopyWarning警告。这个警告虽然不会直接报错,但它提醒你操作可能没有作用在原始DataFrame上,容易埋下隐患。
5.5 快速速查:条件筛选一段式总结
- 单条件:
df[df['col'] > value] - 且:
df[(cond1) & (cond2)] - 或:
df[(cond1) | (cond2)] - 非:
df[~cond] - 属于集合:
df[df['col'].isin(list_of_values)] - 区间内:
df[df['col'].between(left, right)] - 字符串包含:
df[df['col'].str.contains('keyword', na=False)] - 字符串前缀:
df[df['col'].str.startswith('prefix', na=False)] - 空值行:
df[df['col'].isna()] - 非空值行:
df[df['col'].notna()] - 字符串查询语法:
df.query("col1 > 10 and col2 == 'a'")
把这张表存在脑子里或者笔记里,基本能覆盖数据清洗中90%以上的筛选需求。
6. 写在最后:筛选只是开始,清洗才是重头戏
很多人在做数据分析时,会把“筛选”和“清洗”混为一谈。筛选确实能帮你快速缩小数据范围,但清洗要做的事情远不止这些,还包括类型转换(astype())、重命名列、处理重复值、填充空值、排序、合并等。我在实际项目中总结出来的流程往往是这样:先看一眼数据概览(df.info()、df.describe()、df.head()),然后做去重、类型修正,再处理空值,最后才是各种条件筛选。顺序如果反了,你会发现在类型转换之前做字符串筛选,会把数值列误伤成字符串,筛选结果自然不对。
我个人在实际操作中还有一个小习惯:分析前先把原始数据备份一份,不管清洗逻辑多成熟,都要给自己留条后路。因为清洗过程本质上是信息损耗的过程,一旦你发现之前某个决策是错的,比如把某个本应保留的异常值当成脏数据删掉了,没有原始数据的话,你可能得从头再来。有了备份,改动起来就从容多了。
最后再分享一个小技巧:无论是在Jupyter Notebook里做探索性分析,还是在正式的数据管道里写ETL,都建议把一段段筛选逻辑封装成函数,每个函数起一个见名知意的名字,比如remove_duplicated_orders()、filter_invalid_amount()、keep_recent_orders()。这样一来,你的代码可读性、可测试性会直线上升,别人接手你的脚本也不会一脸茫然。毕竟数据处理这行,最终拼的不只是你会不会写筛选语句,而是你能不能把一堆乱糟糟的数据,变成一条清晰、可靠、可复用的处理流水线。
