如何向量化Pandas中同ID指定日期范围的历史状态查询
高效实现同ID指定时间范围内的最后状态查询
你遇到的问题非常典型——循环遍历大DataFrame的每一行进行切片查询,时间复杂度是O(n²),数据量上去之后肯定会慢到离谱。下面我会给你两种向量化/高效的实现方式,都是基于Pandas的原生操作,性能会提升几个数量级。
方法一:利用merge_asof(最简洁高效)
merge_asof是Pandas专门用来按时间进行近似匹配的工具,非常适合这种"找同组内时间范围内最近的前序记录"的场景。步骤如下:
- 先对数据按分组键和时间排序:
merge_asof要求左右表都按by(分组列)和on(时间列)排序,这是前提。 - 构造匹配用的右表:复制原数据并重命名相关列,方便后续匹配。
- 设置时间范围条件:通过计算左表的时间上下限,结合
merge_asof的参数实现范围筛选。
具体代码:
import pandas as pd from datetime import timedelta # 假设你的原始DataFrame是df,包含created_dt, number_id, status列 window_size = 15 gap_size = 5 # 1. 对原始数据按number_id和created_dt排序 df_sorted = df.sort_values(['number_id', 'created_dt']).reset_index(drop=True) # 2. 构造右表:复制排序后的df,重命名列用于匹配 right_df = df_sorted.copy() right_df = right_df.rename(columns={ 'created_dt': 'prev_created_dt', 'status': 'prev_status' }) # 3. 计算左表的时间匹配范围:右表的prev_created_dt需要 >= 当前行created_dt - (window+gap),且 < 当前行created_dt - gap # 先给左表添加时间上下限列 df_sorted['upper_bound'] = df_sorted['created_dt'] - timedelta(days=gap_size) df_sorted['lower_bound'] = df_sorted['created_dt'] - timedelta(days=window_size + gap_size) # 4. 使用merge_asof进行匹配:按number_id分组,按created_dt匹配,找小于upper_bound的最近记录,同时确保>=lower_bound result = pd.merge_asof( df_sorted, right_df, by='number_id', left_on='upper_bound', right_on='prev_created_dt', direction='backward', # 找小于等于left_on的最大的right_on值 allow_exact_matches=False # 排除刚好等于upper_bound的记录(因为我们要<upper_bound) ) # 5. 筛选出在lower_bound范围内的记录,超出范围的设为None result['prev_status'] = result.apply( lambda x: x['prev_status'] if x['prev_created_dt'] >= x['lower_bound'] else None, axis=1 ) # 最后可以把不需要的辅助列删掉,恢复原始顺序(如果需要的话) result = result.drop(['upper_bound', 'lower_bound', 'prev_created_dt'], axis=1) # 如果需要回到原始df的顺序,可以用原始索引合并 # result = result.set_index(df.index).sort_index()
这个方法的时间复杂度是O(n log n)(主要来自排序),比循环的O(n²)快太多,百万级数据也能轻松处理。
方法二:分组+二分查找(适合需要更精细控制的场景)
如果你对merge_asof的参数不太熟悉,也可以用分组结合二分查找的方式,利用组内时间有序的特性快速定位目标记录:
import pandas as pd from datetime import timedelta import bisect window_size = 15 gap_size = 5 # 1. 按number_id分组,每个组内按created_dt排序 df_sorted = df.sort_values(['number_id', 'created_dt']).reset_index(drop=True) groups = df_sorted.groupby('number_id') # 2. 为每个组预处理时间戳列表和status列表,方便二分查找 group_data = {} for num_id, group in groups: # 把时间转换成timestamp(数值类型,方便二分) timestamps = group['created_dt'].apply(lambda x: x.timestamp()).tolist() statuses = group['status'].tolist() group_data[num_id] = (timestamps, statuses) # 3. 定义函数,根据当前行的信息查找符合条件的status def get_prev_status(row): num_id = row['number_id'] current_dt = row['created_dt'] upper_ts = (current_dt - timedelta(days=gap_size)).timestamp() lower_ts = (current_dt - timedelta(days=window_size + gap_size)).timestamp() timestamps, statuses = group_data[num_id] # 找到第一个>=upper_ts的索引,那么前一个就是<=upper_ts的最大索引 upper_idx = bisect.bisect_left(timestamps, upper_ts) if upper_idx == 0: # 没有比upper_ts小的记录 return None # 找到第一个>=lower_ts的索引 lower_idx = bisect.bisect_left(timestamps, lower_ts) if upper_idx - 1 < lower_idx: # 时间范围内没有记录 return None # 返回范围内最后一个status(也就是upper_idx-1位置的) return statuses[upper_idx - 1] # 4. 批量应用函数 df_sorted['prev_status'] = df_sorted.apply(get_prev_status, axis=1) # 同样可以恢复原始顺序 # df_sorted = df_sorted.set_index(df.index).sort_index()
这个方法的核心是用二分查找(bisect模块)代替每次切片,每组内的查找是O(log m)(m是组内行数),整体复杂度也是O(n log n),性能和merge_asof差不多,但是逻辑更直观,方便自定义调整条件。
为什么原代码慢?
原代码的问题在于:
- 每次循环都要对整个DataFrame进行切片筛选(
df[(df.created_dt >= since) & ...]),这会生成新的DataFrame,非常耗时。 - 循环遍历
itertuples本身在大数据集下效率就很低,Pandas的优势是向量化操作,要尽量避免逐行循环。
两种方法都避免了逐行切片,利用排序和Pandas的高效分组/匹配操作,性能提升非常明显。
内容的提问来源于stack exchange,提问作者gonzadevelop
相关产品推荐
相关产品推荐

