在数据分析工作中,我们经常会碰到这样的需求:一张DataFrame记录了若干数值区间,例如不同等级客户的消费下限与上限;另一张DataFrame则是具体的交易记录,包含每一笔的金额。我们希望统计每个区间里匹配到了多少条记录。如果写法不当,计算会非常缓慢,而合理利用Pandas的索引与向量化能力,则能大幅提升效率。

常见低效写法及其问题
不少初学者会采用最直观的思路:遍历区间表的每一行,再在记录表中用布尔条件筛选。这种做法在代码层面容易理解,但在数据量稍大时性能急剧下降。原因在于它本质上是一个双重循环,外层扫区间,内层扫记录,时间复杂度接近O(m*n)。
下面是一段典型的低效示例,我们用两个很小的表来演示逻辑。区间表有3个区间,记录表有若干金额,每判断一个区间就要全表过滤一次。
import pandas as pd
# 区间表:low为下限,high为上限
ranges = pd.DataFrame({
'level': ['A', 'B', 'C'],
'low': [0, 100, 500],
'high': [100, 500, 1000]
})
# 记录表:订单金额
records = pd.DataFrame({'amount': [50, 120, 300, 600, 800, 90, 450]})
result = {}
for _, row in ranges.iterrows():
cnt = ((records['amount'] >= row['low']) & (records['amount'] < row['high'])).sum()
result[row['level']] = cnt
print(result)
上述代码在m和n都很小的时候可以正常运行,但如果ranges有上千行、records有几十万行,脚本可能要跑几分钟。而且这种写法难以利用Pandas底层的C语言优化,纯粹靠Python层循环拖慢了速度。
除此之外,手动写条件还会引入边界错误。例如当区间设置为[0,100]和[100,500]时,金额正好等于100会被两个区间同时计入,导致总数多于实际记录数。明确区间开闭规则是后续优化的重要前提。
利用IntervalIndex进行向量化匹配
Pandas提供了Interval和IntervalIndex对象,专门用来表示区间并构建索引。我们可以把区间表的每一行变成一个Interval,再用这些区间组成索引。之后调用interval_index.get_indexer方法,就能一次性把记录表中的每一个值映射到对应区间位置,全程向量化,无需Python层循环。
下面的代码演示了核心步骤。注意我们统一使用左闭右开区间,即包含下限、不包含上限,这样相邻区间不会重叠。
import pandas as pd
ranges = pd.DataFrame({
'level': ['A', 'B', 'C'],
'low': [0, 100, 500],
'high': [100, 500, 1000]
})
records = pd.DataFrame({'amount': [50, 120, 300, 600, 800, 90, 450]})
# 构建区间索引,closed='left'表示左闭右开
intervals = pd.IntervalIndex.from_arrays(ranges['low'], ranges['high'], closed='left')
ranges.index = intervals
# 将每条记录的金额映射到区间索引位置,-1表示不在任何区间
pos = intervals.get_indexer(records['amount'])
# 统计每个区间匹配到的记录数
counts = pd.Series(pos).value_counts().sort_index()
counts = counts[counts.index != -1]
ranges['match_count'] = 0
for idx, c in counts.items():
ranges.iloc[idx, ranges.columns.get_loc('match_count')] = c
print(ranges)
这段代码里,get_indexer返回的是一个位置数组,长度和records相同。数组里的值如果是0、1、2,就代表落到了ranges的第几行区间;如果是-1,说明不在任何区间内。我们再用value_counts聚合,就得到了每个区间的匹配数量。
相比前面的循环写法,get_indexer在底层使用了二分查找与索引结构,复杂度约为O(n log m),当区间数量远小于记录数量时优势极为明显。同时因为区间定义清晰,边界重复问题从结构上被消除。
使用pandas.cut简化区间计数
如果区间表本身是由一组连续的边界值定义的,例如0、100、500、1000,那么还可以用pandas.cut直接把连续值分箱,再结合groupby计数。这种方式代码更短,也更容易阅读,适合边界规整的场景。
示例中将记录金额按照给定边界切分,并返回每个区间对应的标签,随后用groupby.size得到计数。
import pandas as pd
records = pd.DataFrame({'amount': [50, 120, 300, 600, 800, 90, 450]})
bins = [0, 100, 500, 1000]
labels = ['A', 'B', 'C']
records['level'] = pd.cut(records['amount'], bins=bins, labels=labels, right=False)
count_series = records.groupby('level', observed=True).size()
print(count_series)
pd.cut的right=False参数同样指定了左闭右开,保证边界不重叠。observed=True避免输出没有数据的空区间。对于静态、均匀分布的区间,这种写法比手动建IntervalIndex更直观。
不过当区间来自另一张表且并不连续、甚至互相交错时,cut就不再适用,此时仍应回到IntervalIndex方案。因此实际项目中要根据区间来源与结构灵活选择。
性能对比与注意事项
我们用一张有1000个区间、十万条记录的表做简单对比:循环写法通常耗时数秒到十几秒,而IntervalIndex方案基本在毫秒到几百毫秒之间完成。若记录达到千万级,差距会拉大到几十倍以上。
在落地时还有几个细节需要留意。第一,确保区间列的数据类型为数值型,避免字符串比较带来的隐式转换。第二,若记录值可能超出所有区间,要主动处理get_indexer返回的-1,否则聚合时容易误算。第三,当区间有重叠需求时,应明确业务含义,Pandas允许closed参数调整为'both'或'right',但计数逻辑需同步评审。
| 方案 | 适用场景 | 时间复杂度 | 代码复杂度 |
|---|---|---|---|
| 逐行循环筛选 | 极小数据、临时验证 | O(m*n) | 低 |
| IntervalIndex匹配 | 区间来自表、可能不连续 | O(n log m) | 中 |
| pandas.cut分箱 | 边界规整、连续区间 | O(n log k) | 低 |
通过上述三种方式的组合,开发者可以在不同业务形态下都能高效地完成两DataFrame值范围比较与匹配计数,既保证结果准确,也显著提升脚本运行效率。