做数据分析的人几乎没有一个能绕开 NumPy 的条件筛选。我最早用 NumPy 做数据清洗时条件筛选让我又爱又恨——爱的是它一行代码就能把几千行数据处理完恨的是稍不注意就踩进“视图”和“拷贝”的坑里改了一列数据结果原数组也跟着变。后来用熟了才发现数组的条件筛选本质就是一个“生成布尔掩码再用掩码去取数”的过程理解透了这一层所有花哨的写法都能看懂也能自己写出来。这篇文章我打算把 NumPy 条件筛选从基础到进阶完整拆开讲清楚布尔索引、多条件组合、np.where、np.isin、np.argwhere 这些工具各自适合什么场景再用一个完整的数据处理示例把这些操作串起来。不管你是刚接触 NumPy 的小白还是已经写了一段时间但老是在“视图/拷贝”和“多条件逻辑”上翻车的人这篇文章应该都能让你少走几段弯路。1. 条件筛选的核心思路与布尔索引1.1 布尔掩码把“条件”变成数组NumPy 条件筛选最底层的逻辑其实特别简单你拿一个条件去和数组比较得到的是一个由 True/False 组成的布尔数组然后用这个布尔数组作为下标去取原数组里的元素。这个布尔数组就是“掩码”。import numpy as np arr np.array([12, 7, 9, 20, 5, 15]) mask arr 10 print(mask) # [ True False False True False True] print(arr[mask]) # [12 20 15]这一步干了什么arr 10这个操作是 NumPy 的“向量化比较”它会把数组里每个元素逐个和 10 比大小产生一个同样形状的布尔数组。然后arr[mask]就把里面所有为 True 的位置对应的元素取出来。整个过程没有循环或者说循环被 NumPy 用底层 C 语言替你跑完了——这也是为什么 NumPy 做数据筛选比 Python 原生列表推导快很多的原因。我在实际项目里最常用的思路就是不要一上来想“我要筛出哪些数”而是先想“我的条件能不能写成一个布尔表达式”。能写出来后面的活就完成了一半。比如要筛选正数arr 0要筛选负数arr 0要筛选偶数arr % 2 0。条件不同生成的掩码不同但取数的方式完全一样。1.2 布尔索引的三种基本用法布尔索引虽然只有一种语法但在实际使用中我一般会把它分成三种场景这样思路更清晰。第一种是直接拿布尔数组当下标取数就像上面那样适合一次性筛选完就继续处理的情况。第二种是先用 np.where 拿条件成立的位置索引再去取数适合你后续还需要知道“这些数到底在原数组的哪个位置”的场景。第三种是把布尔掩码保存下来重复使用适合要做多次不同筛选或者要把同样条件用在多个不同数组上的场景。premium arr 10 # 后续复用 top_values arr[premium] bottom_values arr[~premium]这里~是取反操作把 True 变 False把 False 变 True实现“非”的逻辑。我第一次用的时候经常把它和!搞混Python 里!是语法错误NumPy/布尔数组取反只能用~这个习惯最好一开始就养成。1.3 一维数组和多维数组的差异一维数组的布尔索引直来直去但到了二维数组情况会稍微复杂一点。默认情况下arr[布尔数组]作用在二维数组上时布尔数组会按“行”来筛选。matrix np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) row_mask np.array([True, False, True]) print(matrix[row_mask]) # [[1 2 3] # [7 8 9]]这里布尔数组的长度是 3对应矩阵的 3 行取出来的结果是第 0 行和第 2 行。如果布尔数组的形状和矩阵完全一样3 行 3 列那筛选出来的就是按行展平后的元素而不是保留矩阵形状。element_mask matrix 4 print(matrix[element_mask]) # [5 6 7 8 9]注意结果是一维数组因为条件为 True 的元素个数是不固定的NumPy 无法保证能拼成一个矩形所以只能“压平”输出。这个细节我一开始没注意后来想按掩码去修改二维数组时才意识到掩码形状不同返回的东西完全不同。2. 多条件组合与复杂筛选2.1 逻辑运算符、|、~ 和 and、or 的区分多条件筛选是条件筛选里最容易出问题的地方。Python 原生的and、or不能直接用在 NumPy 布尔数组上必须用位运算符与、|或、~非而且每个条件都要加括号。arr np.array([12, 7, 9, 20, 5, 15]) result arr[(arr 5) (arr 15)] print(result) # [12 7 9](arr 5) (arr 15)这里如果不加括号写成arr 5 arr 15就会报错。因为的优先级比、要高NumPy 会先去算5 arr结果完全不是你要的东西。这种错误在初学者里非常普遍我自己也栽过后来统一养成了“凡是用、|、~连接比较表达式一律给每个比较表达式套括号”的习惯再也没出过事。还有一个必须提醒的点and和or不是完全不能用但它们只能用于单个布尔值不能用于数组。比如if (arr 5).any():里的(arr 5).any()返回的是单个 True/False这时才能用and。数组和标量的界限一定要分清楚。2.2 数值边界闭区间、开区间与浮点数精度筛选数值时边界处理特别容易埋雷。比如筛选“大于等于 5 且小于等于 15”你写成result arr[(arr 5) (arr 15)]这是闭区间。如果只需要大于 5 且小于 15就把等号去掉。看起来很简单但浮点数场景下问题就来了。比如你想筛出接近某个值的数字data np.array([0.1 0.2, 0.3, 0.4]) target 0.3 mask data target print(mask) # [False True False]第一个元素0.1 0.2在计算机里并不完全等于 0.3这是浮点数表示的经典问题。在条件筛选时如果你用去匹配浮点数很容易漏掉本该匹配的值。我的做法是用np.isclose先做近似比较或者先对数据做一次np.round处理再进行筛选mask np.isclose(data, target, atol1e-8) print(data[mask]) # [0.3 0.30000000000000004]浮点数的边界问题在金融数据、科学计算里特别常见比如筛选价格恰好等于某个水平线的数据用就经常筛不出东西。用np.isclose或者其他带容差的方式才是稳妥的做法。2.3 字符串筛选与缺失值处理数值之外字符串数组的筛选也会经常用到。NumPy 的字符串操作走的是np.char模块比如np.char.startswith、np.char.endswith、np.char.contains返回的也是布尔数组可以和数值筛选一样用。fruits np.array([apple, banana, cherry, date]) mask np.char.startswith(fruits, b) print(fruits[mask]) # [banana]这里np.char.startswith是逐个元素判断开头是否匹配返回布尔数组。注意np.char在 NumPy 2.0 之后改名成了np.strings老代码可能需要做适配。如果你发现自己用的是新版本np.strings才是正确的入口。缺失值处理是个大坑。NumPy 里np.nan不等于任何值包括它自己。所以你直接写arr np.nan是筛不出任何东西的必须用np.isnandata np.array([3.0, np.nan, 5.0, np.nan, 7.0]) mask ~np.isnan(data) # 筛出非缺失值 print(data[mask]) # [ 3. 5. 7.]这个坑我见过太多次了几乎每个刚开始处理含 NaN 数据的人都写过arr np.nan。记住NaN 的“身份”是无法通过等值比较获得的只能用np.isnan这类专用函数。3. 三个高频工具np.where、np.isin、np.argwhere3.1 np.where既能查位置也能做条件赋值np.where可以说是条件筛选场景里最灵活的工具它有两种完全不同的用法。第一种用法是np.where(条件)此时返回的是条件成立的位置索引。注意这里不是返回元素本身而是索引。一维数组返回的是一个元组里面有一个数组二维数组返回的则是两个数组分别对应行索引和列索引。arr np.array([12, 7, 9, 20, 5, 15]) indices np.where(arr 10) print(indices) # (array([0, 3, 5]),) print(arr[indices]) # [12 20 15]这种用法适合需要知道“哪些位置”而不是“哪些值”的场景。比如你在做异常值检测找到异常位置后可能要去修正原数组里的这些位置而不是把它们取出来。第二种用法是np.where(条件, 值A, 值B)实现“三目运算”的效果条件为 True 的位置用值 A条件为 False 的位置用值 B。scores np.array([58, 72, 91, 45, 66, 82]) labels np.where(scores 60, pass, fail) print(labels) # [fail pass pass fail pass pass]这个写法在数据清洗里太常用了。比如把缺失值填充为 0把负值统一截断为 0把某个阈值以上的数据标记为“高”都能用一行代码完成。3.2 np.isin白名单匹配的利器如果我要筛选出数组中属于某个集合的值比如只保留[3, 7, 11]这几个数用np.isin是最直接的方式。arr np.array([1, 3, 6, 7, 9, 11, 15]) mask np.isin(arr, [3, 7, 11]) print(arr[mask]) # [ 3 7 11]np.isin的作用是逐个判断数组元素是否出现在第二个集合里返回布尔数组。这个函数在处理类别数据、ID 列表匹配时特别管用。比如你在做用户分析手上有一个行为日志数组和一个“目标用户 ID 列表”直接用np.isin(logs, target_ids)就能把目标用户的日志全部筛出来。np.isin和np.in1d很像我在旧项目里见过不少np.in1d的用法但np.isin更推荐它语义更清晰遇到多维数组也更安全。3.3 np.argwhere把符合条件的位置记下来np.argwhere和np.where的第一次用法有点像但返回值形式不同。np.where返回的是“按维度拆分”的索引元组np.argwhere返回的是“每个匹配元素所在坐标”的二维数组。matrix np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) positions np.argwhere(matrix 5) print(positions) # [[0 2] # [1 1] # [1 2] # [2 0] # [2 1] # [2 2]]这里每一行就是一个元素的行、列坐标。如果你要遍历每个符合条件的元素或者要把位置传给某个可视化库去标记np.argwhere的格式往往比np.where更方便——不需要再用zip(*indices)去组合了。选择哪个函数全看后续操作需要什么形态的数据。4. 完整实操一个数据清洗到特征工程的案例4.1 案例背景与原始数据只看零碎的函数用法很难真正掌握条件筛选。我拿一个模拟场景来做完整演示假设你拿到一份传感器数据里面包含温度和湿度两列数值数据里有缺失值有异常值温湿度不可能为负还有一部分记录需要根据阈值打标签最终要把清洗后的数据输出为新的数组供后续分析使用。raw np.array([ [25.3, 60.1], [26.8, 58.4], [-1.2, 65.0], # 温度异常负值 [24.9, np.nan], # 湿度缺失 [30.5, 72.3], [np.nan, 55.9], # 温度缺失 [28.1, 68.8], [99.9, 66.2] # 温度异常超过合理范围 ])这份数据一共有 8 行每行是一条记录第一列是温度第二列是湿度。现在的目标很明确把温度在[0, 50]之外的数据视为异常并剔除把湿度缺失的剔除把温度缺失的剔除然后对剩下的数据做均值填充当然这里已经剔除了缺失所以先做剔除后填充就不再需要实际中可以按顺序处理。4.2 逐步清洗与筛选第一步先生成两个掩码。一个用来筛温度正常大于等于 0 且小于等于 50一个用来筛湿度非缺失同时还要筛温度非缺失temp_ok (raw[:, 0] 0) (raw[:, 0] 50) hum_ok ~np.isnan(raw[:, 1]) temp_not_nan ~np.isnan(raw[:, 0]) mask temp_ok hum_ok temp_not_nan cleaned raw[mask] print(cleaned) # [[25.3 60.1] # [26.8 58.4] # [30.5 72.3] # [28.1 68.8]]注意这里的raw[:, 0]取的是第一列温度raw[:, 1]取的是第二列湿度。多个条件用组合最后得到一个总掩码再一次性作用到二维数组上。结果会按“行”筛掉不合条件的记录保留下来的数据格式仍然是二维数组每一行还是原来的温度、湿度结构。第二步打标签。对清洗后的数据需要根据温度是否高于 28 来生成一个“高温”标签列。这正好用np.where的第二种用法labels np.where(cleaned[:, 0] 28, high, normal) print(labels) # [normal normal high normal]我把清洗、筛选、打标签这三个操作拆开来看每个都清晰独立。实际项目里我会把这些操作封装成一个个小函数每个函数只做一件事返回结果也只有一个数组或掩码。这样调试的时候可以单独测试每一个环节不容易整体跑崩。4.3 二维数组按列/按行筛选的进阶写法上面的例子是按行筛选记录。但实际场景中也可能需要按列筛选。比如数据有 4 列只想保留方差大于某个阈值的列那就要对“列”做掩码。col_mask np.array([True, False, True, False]) selected raw[:, col_mask]关键在于理解raw[行掩码]和raw[:, 列掩码]的区别。前者是在第 0 维行上筛后者用:表示“所有行都要”再在第 1 维列上筛。这两种写法别搞混也别图省事省略掉:否则结果形状完全不一样。另外二维数组如果只筛选出一个元素取出来的可能是一个一维数组而不是标量。比如cleaned[cleaned[:, 0] 29]如果只剩一行返回的形状是(1, 2)而不是(2,)。这个“维度保留”问题在后续和别的数组拼接时经常造成麻烦我建议处理前先打印一下结果的shape心里有数。4.4 性能优化用条件筛选代替 Python 循环条件筛选最重要的一项优势是性能。我印象很深的一次优化是在一个仿真项目里需要对百万级数组做逐元素条件判断。一开始写的是 Python 的 for 循环跑一次要几十秒改成 NumPy 条件筛选后基本上一次操作在几十毫秒级别性能差距是几百倍。# 慢Python 循环 result [] for v in large_array: if v 100: result.append(v * 2) else: result.append(v) # 快NumPy 条件筛选 large_array np.array(large_array) # 数据量大时才值得转换 result np.where(large_array 100, large_array * 2, large_array)这不是说 Python 循环一无是处而是 NumPy 在底层是用 C 写的连续内存块计算循环和数据操作都被高度优化。只要能用向量化操作就不要手动循环。这不是编程风格偏好问题而是实打实的性能差距。5. 常见问题与排查技巧5.1 视图与拷贝改一个另一个也变了这是我见过的、也是在条件筛选里最容易被忽视的坑。用布尔索引取出子数组后这个子数组有时是原数组的“视图”有时是“拷贝”区别在于你对子数组做修改原数组会不会跟着变。arr np.array([10, 20, 30, 40, 50]) sub arr[arr 20] sub[0] 99 print(arr) # [10 20 30 40 50]这里arr[arr 20]返回的是拷贝修改sub不会影响arr。但如果是用“切片”的方式截取比如arr[1:4]那返回的是视图修改视图就可能影响原数组。布尔索引和切片的行为不一致如果不是很清楚我的建议是无论是什么样的筛选结果如果后续要修改子数组且不想动原数组直接显式调用.copy()sub arr[arr 20].copy()多写一个.copy()几乎没什么成本但能彻底避免修改副作用。如果要利用“视图”的特性去原数组上做定点修改那就反过来必须确认你现在拿到的是视图而不是拷贝。5.2 空数组与维度塌缩条件筛选的结果可能为空。这在逻辑上没问题但后续操作如果没处理好会报错或者静默出错。比如你对一个空数组求mean()得到的是nan并伴随警告如果你把空数组和正常数组拼接形状又对不上。empty_result arr[arr 1000] print(empty_result.shape) # (0,)实际项目里我通常在筛选后先判断len(result) 0再决定要不要继续处理。如果为空可以直接返回一个默认值或者跑另一个分支避免后续“维度对不上”的连锁错误。还有一个维度问题需要提醒arr[mask]的结果永远是一维的只要 mask 是一维布尔数组哪怕原数组是多维的。这意味着如果用布尔索引去筛选二维数组的某些行结果还能保持二维但如果用和整个矩阵同形状的布尔掩码去筛结果会被压成一维。很多视觉任务里用掩码选取像素点后还要还原成图像矩阵这个地方就特别容易出错需要reshape或者重新构造矩阵。5.3 布尔值参与运算的陷阱NumPy 的布尔值是bool类型但它和整数之间存在隐式转换True 是 1False 是 0。这在某些场景下会让“条件筛选”的代码出现难以察觉的 bug。arr np.array([1, 2, 3, 4]) mask arr 2 # 如果你不小心对 mask 做加法 print(mask 1) # [1 1 1 2]mask 1的结果是数字而不是布尔值如果用这个结果去索引数组就完全不是筛选而是位置索引了。这类问题在长代码里极难发现而且不会报错属于“静默错误”中最折磨人的一类。我的习惯是布尔数组一旦生成不要把它和其他数值数组混着运算如果必须参与数值计算先显式转成int类型明确意图mask_int mask.astype(int) print(mask_int.sum()) # 统计满足条件的数量5.4 反向筛选与边界条件的自检反向筛选也很常见比如“筛选出不符合条件的记录”。做法有两种一是对掩码取反二是直接用~操作符。注意~的优先级比比较运算符低所以也要加括号。mask (arr 10) (arr 20) complement arr[~(mask)]这里如果你漏了括号写成arr[~mask]看起来没问题但如果你写成arr[~ mask other_mask]优先级就会出问题。所以只要用了~哪怕只是一元操作我建议连同整个掩码一起用括号抱起来。另外说一个我在做边界条件测试时的做法写完筛选条件后我会故意构造几个“边界值”数组包含恰好等于阈值、略小于阈值、略大于阈值、NaN、空数组全部跑一遍确认结果符合预期。这比在真实数据上反复调试要高效得多。做数据清洗边界条件往往是出 bug 最多的地方。写在最后条件筛选看起来只是“取值操作”但它背后牵扯到数组的底层存储、视图与拷贝机制、逻辑运算优先级、浮点数精度、缺失值语义以及一维和多维之间的维度变化。任何一个细节没想清楚都可能写出“结果正确但效率很低”或者“特定数据下才出错”的代码。我在实际项目中用得最多的组合方式就是“先构造布尔掩码再统一取数”这个思路几乎覆盖了 90% 的筛选需求。剩下的特殊情况比如要位置索引用np.where要白名单匹配用np.isin要打标签用np.where(cond, a, b)要坐标列表用np.argwhere——每个工具都有它最顺手的场景不必强求一个函数打天下。最后再分享一个小技巧当你拿到一份全新的数组数据别急着写筛选代码先花半分钟打印一下数组的形状、dtype 和几条样本数据心里过一遍可能存在的缺失值、异常值、边界值。这一小步能替你挡掉后面至少一半的排查时间。条件筛选本身不难难的是把各种边界情况都想到并用最简单、最可读的方式把它们一次性处理干净。