ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pandas resample超全指南:时间序列分组聚合与降采样升采样实战

Pandas resample超全指南:时间序列分组聚合与降采样升采样实战

提起时间序列数据处理,订单流水、传感器上报、日志采集,几乎每一项都绕不开DataFrame.resample()这个操作。我在自己的数据分析项目里,从日活报表、按周收益统计到季度特征工程,resample 都是最高频使用的方法之一。这篇文章的目标读者有两类:一类是刚接触 pandas、想快速掌握时间聚合的新手,另一类是已经在用 resample,却总在标签错位、升采样填充这些细节上栽过跟头的同学。我会把参数背后的逻辑、实际场景里的操作步骤,以及踩过的坑一次讲清楚。

先说一个核心观点:resample()表面上只是一个频率转换工具,但你把它的机制想透了,会发现它其实就是“时间维度上的 groupby”。围绕这一点,后续所有参数和边界问题都能找到解释。

1. resample 到底是什么:时间维度上的分组聚合

1.1 从 groupby 的角度理解 resample

groupby做的事情,是把“属于同一个类别”的行放进同一个桶里,比如把所有用户 ID 相同的订单汇总,计算总金额;resample()做的事情本质上完全一样,只不过分组的依据从“类别”变成了“时间段”。每天、每小时、每个自然周都是一个桶,桶内的数据被丢进你指定的聚合函数里做汇总。带着这个视角去看 resample,很多困惑都会迎刃而解。

我早期写时间聚合,习惯用to_period配合groupby:

df.groupby(df.index.to_period('D'))['amount'].sum()

先把时间索引转成周期类型,再按周期分组。现在我会直接写:

df.resample('D')['amount'].sum()

两种方式的结果基本等价,但resample()的表达更清晰,它明确告诉你“我要做时间维度的重采样”,底层会自动帮你处理区间划分、端点闭合、标签生成这一整套逻辑。有人觉得这只是一个语法糖,实际上并不是,resample()和groupby在 Pandas 底层共用同一套分组聚合引擎,只是分组键从普通类别换成了由时间频率生成的区间数组。

使用resample()有个前提条件:要么 DataFrame 的索引是DatetimeIndex,要么通过on参数指定一列时间列。如果索引是普通整数或字符串,直接调resample()会得到一个TypeError,告诉你索引类型不支持。

1.2 降采样与升采样的本质区别

重采样有两个方向,理解它们的差异比记住一百个参数都重要。

降采样(downsampling)是把高频数据聚合到低频,比如把每分钟的流量聚合成小时、把小时聚合成天、把天聚合成周。降采样是信息压缩过程,行数变少,必须提供聚合函数,比如 sum、mean、max、last,告诉程序“一个时间段里的多个值如何变成一个值”。这个方向的逻辑很直白。

升采样(upsampling)则相反,是把低频数据扩展到高频,比如把日数据补成小时数据、把周数据补成日数据。很多人误以为升采样会自动“智能插值”,实际上 resample 默认只会把时间网格变密,新增的时间点上没有数据,就会出现一堆 NaN。升采样本身不产生任何新信息,数值完全靠后续的填充或插值来补。

我见过不少人拿到日温度数据,想重采样成小时数据,结果发现全是 NaN,以为数据丢了。这里的关键不是补值,而是想清楚:这一天到下一日之间的每个小时,到底应该怎样估算?前向填充、后向填充还是线性插值,必须根据业务场景判断。这个区别我会在第四章用一整节来讲。

2. 核心参数逐个拆解:rule、label、closed、origin 背后的逻辑

2.1 rule 频率字符串的选型:从 H、D 到 W、M、Q 到底怎么选

resample()的第一个参数rule决定按什么频率切分时间轴,它本质上是一个 Pandas 的 offset 字符串。常用频率有H(小时)、D(自然日)、W(周)、M(月)、Q(季度)、Y(年度),同时支持2H、15min、3D这种倍数组合。注意T和min都表示分钟,旧代码里常见T,新版本推荐min,两者在大多数情况下可以混用。

这里有个非常容易被忽略的细节:M和MS虽然都是按月分组,但区间端点完全不一样。M默认使用“区间右端点闭合”,聚合结果的标签会落在每个月最后一天;MS则把月初当天作为标签。我用resample('M')给运营出月度报表时,第一次看到索引变成月末日期还觉得奇怪,后来才明白这是默认行为导致的。

我整理了一张频率对照表,平时查频率时可以对着看:

频率字符串含义默认标签位置
T/min分钟区间起点
H小时区间起点
D自然日区间起点(当天 00:00)
W周区间终点(默认周日)
M月月末当天
MS月初月初当天
Q季季度最后一天
QS季初季度第一天
Y年年末当天
YS年初年份第一天

这张表只代表默认行为,实际结果如何还要看label参数,这是下一节要讲的内容。

2.2 label 与 closed:你总会踩的“区间标签错位”坑

label和closed是 resample 里最容易让人绕晕的两个参数。closed决定一个时间区间左右端点的开闭关系,也就是“边界上的数据到底归左边还是右边”;label则决定聚合结果的时间戳取这个区间的哪一边作为输出标签。

先说结论:对于小时、分钟、秒这种能整除 24 小时的频率,默认是closed='left'、label='left',一个小时的桶用它的左端点作为标签;对于月、季、年、周这种频率,默认变成closed='right'、label='right',标签自然落在月末、季末、周末。

这个设计是有原因的。小时段我们习惯于说“10 点到 11 点”或“10 点这一小时”,用左端点最自然;而一个月的数据大家更习惯说“这个月”,用右端点反而贴合直觉。但业务不一定都按默认来,你必须根据数据的原始语义去调整。

一个真实案例:某系统上报的数据,每条记录的时间戳代表“这个小时结束的时间”,也就是说 01:00 那条记录实际属于 00:00 到 01:00 这个区间。按天聚合时如果直接用resample('D').sum(),默认会把 00:00 作为标签,看起来还算合理;但如果时间戳是区间右端点,那么每天 00:00 的数据实际应该归到前一天,这时候不改closed,累计值就会整体偏一天。

实操里,我建议每次都把这两个参数写成显式形式,不要依赖默认值,尤其当数据来自第三方系统、时间语义不明确时:

df.resample('D', closed='left', label='left').sum()

如果发现聚合结果的时间戳和业务对不上,先别怀疑数据,回头检查一下是不是端点设置问题。

2.3 origin 与 offset:对齐业务周期和修正时间戳

origin参数用来指定分组区间的对齐起点。Pandas 默认会以一个内置参考点作为对齐起点,大多数场景没问题,但遇到“业务周期不从整点开始”的数据就麻烦了。比如某公司的运营周期从早上 6 点开始,想把 24 小时按“6:00 到次日 6:00”切分,就需要显式指定origin。

df.resample('D', origin=pd.Timestamp('2025-01-01 06:00:00')).sum()

这个参数在排班系统、跨天的夜班数据、按结算周期切分的报表里非常有用。另一个容易被混淆的点是offset参数。它负责在聚合完成后把结果的时间戳整体平移一段偏移量,适合用来把“区间结束标签”改成业务上更关心的时刻。早期版本里这个参数叫loffset,在 pandas 2.x 中已经标记为弃用,现在建议直接用offset,或者聚合完之后自己给索引加一个Timedelta,后者更直观:

result = df.resample('M').sum() result.index = result.index + pd.Timedelta(days=1)

这里要强调一句:origin改变的是分组边界位置,直接影响每条数据归到哪个桶;offset改变的是输出标签的位置,不影响分桶结果,只影响结果显示的时间点。把这两者区分清楚,很多诡异的边界数据问题就自然想通了。

3. 降采样实操:从原始数据到可分析的视图

3.1 基础聚合:日、周、月数据的快速生成

前面讲了一堆原理,现在进入实战。先给出一份模拟数据,方便你直接跑起来看效果。假设这是一份订单流水,时间列放在索引里,字段有订单号、金额、用户 ID:

import pandas as pd import numpy as np df = pd.DataFrame({ 'order_id': [1001, 1002, 1003, 1004, 1005, 1006], 'amount': [120, 250, 300, 150, 400, 220], 'user_id': [1, 2, 1, 3, 2, 1] }, index=pd.to_datetime([ '2025-01-01 10:00', '2025-01-01 14:00', '2025-01-02 09:00', '2025-01-02 18:00', '2025-01-03 11:00', '2025-01-03 16:00' ])) df.index.name = 'ts'

按天统计销售额和订单数,最简单的写法:

daily = df.resample('D').agg({ 'amount': 'sum', 'order_id': 'count' })

结果里每一天只保留一行,索引是每天 00:00,amount是当天销售额,order_id是当天订单数。有一个点需要特别注意:resample()会按照完整的时间网格分组,原始数据里完全没有记录的日期不会出现在结果中。比如节假日没有订单,那这一天就不会出现在报表里,但业务上往往希望缺失日期也显示为 0。这种情况可以先对索引做reindex补全连续日期,再重新调整业务字段:

full_idx = pd.date_range(start='2025-01-01', end='2025-01-10', freq='D') daily = daily.reindex(full_idx, fill_value=0)

按周、按月、按季度聚合只是换一个rule的事:

df.resample('W').agg({'amount': 'sum', 'order_id': 'count'}) df.resample('M').agg({'amount': 'sum', 'order_id': 'count'}) df.resample('Q').agg({'amount': 'sum', 'order_id': 'count'})

如果你更希望用“周一作为每周起点”来做自然周统计,用'W-MON',或者干脆自己指定label和closed,不要被默认标签搞糊涂。

3.2 多列多规则聚合:不同指标不同口径

业务场景里很少所有字段都用同一个聚合函数。销售额要求和、订单数要计数、用户数要去重、库存可能要取最后一条,这种多规则聚合就是 resample 最顺手的地方:

result = df.resample('D').agg({ 'amount': 'sum', 'order_id': 'count', 'user_id': 'nunique' })

agg的入参是一个字典,列名对应聚合方式。nunique用来求唯一用户数,比先groupby再去重再合并要省事很多。你也可以传一个列表,比如df.resample('D').agg(['sum', 'mean', 'count']),结果会带多层列索引,之后用result['amount']['sum']访问列即可。

用agg还有一个很大的优势:它是向量化操作,一次扫描完成所有列的计算。对比几个列逐个sum再拼接,代码可读性和性能都会更好。处理几百万行订单数据时,这个差别非常明显。我见过有些同学为了求去重用户数,写一个自定义函数丢给 apply,数据量一上去就跑好几分钟。其实内置的nunique已经覆盖了这种需求,不需要自定义。

3.3 自定义聚合逻辑:apply 与更多内置函数

内置函数覆盖大多数需求,但总有特殊情况要自己写逻辑。比如想统计“每天里有多少笔订单金额超过 200 元”,可以写:

def high_sales_count(x): return (x['amount'] > 200).sum() df.resample('D').apply(high_sales_count)

apply会把每个时间段对应的子 DataFrame 传给函数,你可以在这个子集上做任意操作。但记住一句话:能不用apply就不用。它的性能实在不行,而且会打断向量化链路。我的经验是先加辅助列,把判断提前算好,然后再聚合:

df['high_flag'] = (df['amount'] > 200).astype(int) df.resample('D')['high_flag'].sum()

这个写法和自定义函数效果完全一致,但执行速度能快一个数量级。类似的思路可以用在很多场景里,凡是能拆成“列级别运算 + 聚合”的逻辑,都不要丢给 apply。

还要提醒一个细节:resample('D').size()返回每个时间段内的记录数,等价于对任意列做count();但sum()遇到NaN会当作 0 处理吗?不会,NaN默认是被跳过的,如果你希望缺失值按 0 参与统计,要提前fillna(0)。

4. 升采样与填充:从低频到高频的补全方案

4.1 asfreq 与缺失值处理逻辑

升采样的经典场景是把日数据展开成小时数据。假设我们有一份日销量统计:

daily = pd.DataFrame({ 'sales': [2400, 3600] }, index=pd.to_datetime(['2025-01-01', '2025-01-02']))

直接重采样到小时频率:

hourly = daily.resample('H').asfreq()

得到的表格里,sales全部变成 NaN,只有原始数据对应的两天 00:00 两个点有值。asfreq()的作用非常纯粹,它只负责改变时间索引的频率,不做任何填充和聚合,是升采样最底层的操作。

为什么会有 NaN?因为升采样本质上是在时间轴上插入更多时间点,而这些新增点上并没有原始观测值,程序不会凭空产生数字。所有后续的填充、插值,都是在你插入完这些时间点之后才补上的。这里建议把“插入时间点”和“填充数据”两步分开理解:asfreq()做第一步,ffill()/bfill()/interpolate()做第二步。

如果你想每个时间桶内直接取某个已有值,也可以用resample('H').last()或first(),不过对升采样来说,大多数情况下窗口里只含一个非空值,效果和 asfreq 差别不大。关键在于,如果你忽略填充这一步,报表里就会出现大量空洞。

4.2 前向填充、后向填充与插值的选择

填充策略不能乱选,必须跟着业务语义走。

对于库存水位、人员状态、配置版本这类“上一次值持续有效”的数据,用ffill()最合适,含义是“下一个有效值出现之前,沿用最近一次值”。反方向用bfill(),适合某些希望用未来值来补全的场景。对于温度、价格这类连续变化的数值,线性插值更自然:

daily.resample('H').ffill() daily.resample('H').bfill() daily.resample('H').asfreq().interpolate()

这里藏着一个很多老手都会忽略的细节:interpolate()默认按行位置等分插值,并不感知真实时间间隔。如果你的原始索引间隔不均匀,它插出来的值会和真实时间趋势有偏差。想按真实时间差插值,要显式指定method='time':

daily.resample('H').asfreq().interpolate(method='time')

还有更复杂的业务场景,比如“日销量按小时均摊”。直接把日销量升采样成小时再用ffill(),结果会是每小时等于当天总量,数值直接膨胀 24 倍,完全失真。正确思路是先拆分再填充:

daily['per_hour'] = daily['sales'] / 24 daily[['per_hour']].resample('H').ffill()

这样每个小时得到的是日均值,总和不失真。这种“先按业务口径拆分,再填充”的思路,在实际项目里比直接无脑填充重要得多。升采样本身不创造信息,任何填充都带着假设,没有业务依据就随意补数,报表会从“稍微不准”变成“完全失真”。

5. 常见问题与排查实录

5.1 索引不对与 on 参数的使用

最常见的报错大概是:

TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex

看到这个错误,基本可以判定索引不是时间类型。解决办法是把时间列先转成 datetime 再设为索引:

df['ts'] = pd.to_datetime(df['ts']) df = df.set_index('ts')

如果你的时间信息在普通列里,又不想改变索引,可以直接用on参数:

df.resample('D', on='ts').agg({'amount': 'sum'})

on参数让resample不依赖索引,灵活性更高。字符串格式的时间列也可以传给on,但建议先统一to_datetime转换一遍,避免格式不一致导致排序或窗口划分异常。

另一个容易被忽视的问题是索引顺序。resample内部会自动排序,所以这个坑不会经常遇到;但如果数据量大、索引有重复,还是建议先sort_index()。另外,索引重复不会让resample报错,但同一个时间点的多条记录都会被聚合进同一个桶,如果你期望它们分开处理,需要先想清楚去重逻辑。

我整理了一份常见问题速查表,方便快速定位:

现象可能原因解决方案
TypeError: Only valid with DatetimeIndex...索引不是时间类型to_datetime+set_index,或使用on参数
结果索引是月末/周末M/W默认右端点标签使用MS/W-MON,或指定label='left'
升采样后大量 NaN没有做填充/插值按业务做ffill/bfill/interpolate
聚合结果差了 1 天时间戳代表区间右端点调整closed='right'或前置shift
loffset警告或报错pandas 2.x 已弃用改用offset,或聚合后手动移动索引
有数据的日期没有出现在结果里按完整时间网格分组用reindex补全日期并填充

5.2 时区、性能与内存:真实场景的几个大坑

带时区的索引很容易给人添堵。如果索引是带时区的DatetimeIndex,resample会保留时区信息。但在使用夏令时的地区,个别小时可能不存在或者重复,聚合结果会变得诡异,数据和直觉对不上。我的建议很简单:存储时统一用 UTC,等聚合完成后再转目标时区展示,尽量避免在本地时区上直接分桶。

性能方面,resample本身是向量化操作,几百万行数据做常规agg并不慢,真正慢的地方经常出在apply和对象类型的列上。如果表里有大量字符串列,比如备注、大段文本,基于这些列做聚合时要特别小心,能用count、first、last就用这些轻量函数,不要手写复杂逻辑。

内存方面,升采样会显著放大行数。日数据升采样到小时,行数膨胀 24 倍;周数据升采样到小时,直接膨胀 168 倍。做大规模特征工程时,升采样后的 DataFrame 可能瞬间占掉几个 GB 内存,提前评估行数增长,必要时拆分时段处理,或者用稀疏数据结构控制内存占用。

版本坑也不能忽视。旧代码里常见loffset=来移动结果索引,在 pandas 2.x 里已经报FutureWarning,后续版本会移除。从老项目迁移代码时,务必把loffset改成offset,或者干脆聚合完之后用Timedelta手动移动索引,这样最稳。

6. 实战心得:从原始数据到报表的完整处理思路

6.1 拿到时间序列数据后的处理顺序

把前面所有知识点串起来,我分享一下自己处理时间序列数据的习惯顺序。第一步不是写代码,而是确认三件事:索引是不是时间类型、时间戳代表区间起点还是终点、数据里有没有缺失时间段。这三点想清楚,再开始写 resample 就能少走许多弯路。

第二步是选择合适的频率和聚合函数。这里要结合业务口径:报表明细需要看每天的累计,用D;分析用户长期规律用W或M;特征工程里经常要用H甚至15min。聚合函数也要先明确,销售额用 sum,库存用 last,客单价用 mean,不要把所有列都用同一个函数一把梭。

第三步才是写代码,并且尽量写显式参数。我的习惯是这样的:

freq = 'D' daily_df = ( df.resample(freq, closed='left', label='left') .agg({'amount': 'sum', 'order_id': 'count', 'user_id': 'nunique'}) .reset_index() )

把rule提取成变量,closed、label显式写出来,后面调整频率时只改一行,也不容易因为默认行为不同而翻车。

6.2 resample 与 groupby 的取舍:什么时候不用 resample

虽然我们把它理解成“时间维度的 groupby”,但有些场景确实不适合用resample(),我在这里也说清楚,免得你走弯路。第一种是多级索引复杂分组,比如“按用户 + 时间”两级聚合,resample做不了,得用groupby([user_id, pd.Grouper(freq='D')])。第二种是自定义非常规的时间区间,比如“累计最近 30 天窗口”这种滚动统计,它本质是一个滑窗而不是固定切分,resample 帮不上忙,得用rolling()或者自定义循环。

第三种是企业里常见但容易误用的场景:两个不同频率的时间序列要对齐,比如日频销售额和周频库存放在同一张表里做分析,很多人直接对两个序列分别 resample 再 join。这个思路没错,但要注意对齐后的索引标签是否一致,否则 join 时会产生错位。更稳妥的方式是各自resample后放到同一个target_freq上,再按索引join,并对填充策略达成共识。

所以我的结论是:resample 是时间聚合的默认首选,但不是万能钥匙。遇到复杂分组和滚动窗口时,要及时切换到groupby和rolling,用对工具比硬写 resample 重要得多。

我在实际项目里踩过不少坑,最深刻的还是一句话:时间序列数据的时间戳语义往往决定了整个分析是否正确。同一个数据,你以为时间戳是区间起点,别人告诉你是区间终点,最终聚合出来的趋势可能完全不一样。拿到任何时间数据,先花两分钟确认这个小问题,远比后面排查错位要省时间。

最后分享一个实测好用的小技巧。如果你经常要在多个频率之间切换,并且每次都写一堆agg和填充逻辑,可以封装一个小函数:

def resample_pipe(df, rule='D', agg_dict=None, how='sum'): if agg_dict is None: return df.resample(rule).agg(how) return df.resample(rule).agg(agg_dict)

这函数虽然简单,但能让调用方只关注频率和口径,不用每次重复记一堆参数。重采样这件事,越用越能体会到,真正值钱的不是 API 本身,而是你对自己数据时间语义的理解程度。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进