☰
pandas resample重采样完全指南:从参数到实战避坑
2026/9/30 8:33:31 网站建设 项目流程

简介:针对 Pandas 时间序列处理中的 resample 重采样方法,这份资料系统梳理了从高频到低频的降采样、从低频到高频的升采样两大核心场景。内容逐一解析 freq、how、axis、fill_method、closed、label、loffset、limit、kind、convention 等关键参数,并结合分钟级 Series 实例演示 sum、asfreq、pad、bfill 以及自定义 apply 的典型用法,帮助数据分析、金融量化、物联网等领域的学习者快速掌握数据频率转换与聚合技巧。资料还涉及时间桶边界控制、标签设置与缺失值填充等实操细节,适合有一定 Pandas 基础、希望深入理解重采样机制的读者。资源以 PDF 格式提供,共 1 个文件,压缩包仅 44KB,轻量便携。目前已有 1850 人学习,对于想系统理解 resample 参数细节和阅读代码示例的用户,具有较好的参考价值。

1. 做行情聚合和数据清洗的人,迟早会撞上 pandas resample 重采样

手里是五分钟一条的行情数据,老板要你出日线、出周线;传感器每 10 秒上报一次温度,要按小时聚合算均值;日志表里一堆时间戳,要按天统计请求量。新手第一反应是把时间戳截断成日期再用 groupby,老手会直接说用 pandas 的 resample。resample 是 pandas 库里专门针对时间序列做重采样的工具,它做的事很简单:把时间轴按规则切块,再在每个块里做聚合或者填充,对应降采样和升采样两条路线。这篇文章按「先搞懂参数、再降采样落地、再升采样处理、最后集中排坑」的顺序讲,适合已经会 pandas 基本操作、但还没系统用过时间序列功能的人。

2. 四个必调参数:rule、closed、label、offset 怎么配合

2.1 rule 参数:频率字符串才是重采样的核心

resample 的第一个参数是 rule,它决定按什么粒度切时间轴。rule 可以是一个频率字符串,也可以是 pandas 的 DateOffset 对象。字符串的规则和 date_range 完全一致:D 表示自然日、B 表示工作日、H 表示小时、T 或 min 表示分钟、S 表示秒、W 表示周、M 表示日历月末、Q 表示季度末、A 表示年末。基础字母前加数字就是倍数,比如15min、2H、3D,这是最日常的写法。

import pandas as pd import numpy as np rng = pd.date_range("2024-01-02 09:31", periods=30, freq="5min") df = pd.DataFrame( { "price": np.cumsum(np.random.randn(30)) + 100, "volume": np.random.randint(100, 500, 30), }, index=rng, ) # 按 30 分钟切块,桶左边界作为标签,桶内价格取最后一笔、成交量求和 bar30 = df.resample("30min").agg({"price": "last", "volume": "sum"}) print(bar30.head())

rule 写成"30min"之后,pandas 从时间轴的起点按 30 分钟一格切出桶边界。上面的 agg 字典里,price 用last取桶内最后一笔成交价,volume 用sum加总桶内成交量。这里最常见的错误是直接df.resample("30min").sum(),那样 price 也会被求和,明显不符合 K 线的语义。所以 agg 字典是重采样最常用的写法,后面几章都按这个来。

B 和 M 这两个频率要特别留意。resample("B")会把周末直接排除,一天一根的日线序列里不会出现周六周日;resample("M")的锚点是日历月末,桶的标签是月末日期。如果拿日内数据直接按 M 切,第一根桶只包含当月最后那一段行情,这个行为不是 bug,是频率本身的定义。拿不准的时候可以把结果打印出来看索引,而不是猜。

rule 参数还接受 DateOffset 对象。pd.offsets.Minute(30)等价于"30min",pd.offsets.Week(weekday=3)类似"W-THU"。字符串能表达绝大部分频率,但节假日这种不规则偏移只有 DateOffset 能表达,这个放到最后一章展开。

2.2 closed 与 label:桶边界决定数据归属,标签只改名字

closed 和 label 是 resample 里最容易被混着调的两个参数。closed 决定每个桶包含哪些点,label 只决定桶的标签取左边界还是右边界。它们是两件事,但很多人以为改 label 就能改聚合结果,实际上改的是索引显示。

# 左闭右开:09:30 到 10:00 之间的数据归到 09:30 这个桶 bar_left = df.resample("30min", closed="left", label="left").agg({"price": "last", "volume": "sum"}) # 右闭左开:09:30 到 10:00 之间的数据归到 10:00 这个桶 bar_right = df.resample("30min", closed="right", label="right").agg({"price": "last", "volume": "sum"}) print(bar_left.head(2)) print(bar_right.head(2))

用 09:31 开始的数据,closed="left"时第一根桶是[09:30, 10:00),包含 09:31 到 09:56 六根五分钟线;closed="right"时桶变成(09:30, 10:00],这六根数据仍然归同一个桶,但标签从 09:30 变成 10:00。如果数据里恰好有踩在 09:30:00 这个点上的记录,两种 closed 对这个点的归属会给出完全不同的结果,这正是后面避坑章节的源头。

实际使用中,M、Q、A 这类日历频率的默认 closed 是 right,也就是桶是「上个边界到这个边界」的右闭区间;D 和 H 频率默认是 left。别依赖默认值,显式写 closed 和 label,同一套代码在不同 pandas 版本之间跑出来的结果才一致。

提示:closed 决定数据归到哪个桶,label 只改这个桶叫什么名字。两个参数不要混着调,否则会出现「数据归到 09:25 桶、标签写 09:30」这种错位。

2.3 offset 与 origin:把锚点从 0 点挪到业务开盘时间

resample 的桶边界默认锚定在时间轴的 0 点。对股票、期货这类有开盘时间的业务,0 点锚会带来一个很尴尬的后果:9:30 开盘的数据会被切进[0:00, 次日 0:00)这种桶里,日线只有早上那一段,看起来像数据缺了大半。offset 就是干这个用的。

# 把日线桶的起点从 0 点平移到 9:30 bar_day = df.resample("D", offset=pd.Timedelta("9 hours 30 minutes")).agg( {"price": "last", "volume": "sum"} ) print(bar_day) # 或者让第一个桶从第一条数据开始切 bar_day2 = df.resample("D", origin="start").agg({"price": "last", "volume": "sum"}) print(bar_day2)

offset 把桶边界整体平移。默认时 D 频率的桶是[0:00, 次日 0:00),09:31 的数据孤零零待在当天的桶里;offset=9h30min之后桶变成[09:30, 次日 09:30),开盘数据就和自然交易时段对齐了。origin 参数在 pandas 1.1 之后提供,origin="start"表示第一个桶从第一条数据开始切,适合回测场景里不想让桶边界受自然日影响的情况。origin 和 offset 不能同时设置,两个只能选一个。

需要注意,offset 不会改变数据进哪个桶的规则,它只是平移桶边界。改 offset 之后,同一条数据从某个桶挪到相邻桶是正常的。这一点很多人想不通,以为是 bug,其实再往下一章看就明白了。

3. 降采样落地:把分钟数据聚合成日线 VWAP

3.1 先给时间列做一次数据清洗

实际拿到手的数据,时间列经常是从 Excel 或 CSV 读进来的字符串,索引还是数字。resample 只认时间索引,所以第一步永远是清洗时间列,这一步做不好,后面全是白搭。

raw = pd.read_csv("tick_sample.csv") # 假设列是 time, price, volume raw["time"] = pd.to_datetime( raw["time"], format="%Y-%m-%d %H:%M:%S", errors="coerce" ) raw.dropna(subset=["time"], inplace=True) df = raw.set_index("time").sort_index()

to_datetime的 format 一定写上。不写 format 时 pandas 会逐条猜测格式,数据量一大就慢得离谱,而且混合格式时猜错的概率很高。errors="coerce"把解析不了的脏值变成 NaT,再统一 dropna 掉。set_index之后必须sort_index,乱序时间索引在 resample 时不会直接报错,但桶内数据的先后顺序可能错,聚合出来的 open、first 这类值就不对。

3.2 agg 组合、OHLC 与 VWAP 一起算

VWAP(成交量加权均价)是期货和股票场景里最常用的指标之一。它不等于价格的均值,而是成交额之和除以成交量之和。最稳的做法是先把price * volume算成一列,再在桶内聚合后相除。

# 先算成交额,再按时段聚合 df["amt"] = df["price"] * df["volume"] bar30 = df.resample("30min", closed="left", label="left").agg( {"price": "last", "volume": "sum", "amt": "sum"} ) bar30["vwap"] = bar30["amt"] / bar30["volume"] bar30.drop(columns="amt", inplace=True) print(bar30.head())

这里 price 取last,volume 和 amt 都取sum,最后一步用两列相除得到 VWAP。有人喜欢写resample(...).apply(lambda x: np.average(x["price"], weights=x["volume"])),这种写法在 pandas 2.x 里能跑,但遇到空桶或者列名不一致时很容易翻车。先乘后加是最朴素也最不容易出错的方式,数据量大的时候速度也更快。

K 线还需要开高低收,直接用 ohlc 方法:

ohlc = df["price"].resample("30min", closed="left", label="left").ohlc() print(ohlc.head())

ohlc()一次性给出 open、high、low、close 四列,是 K 线数据的标准输出。注意这个方法挂在 Series 上才有,DataFrame 对象没有ohlc()方法。如果你手里是多列 DataFrame,先选出一列价格再调用。

3.3 按周、月、季度重采样:日历频率的对齐细节

日线做完之后,下一步往往就是周线、月线、季线。日历频率的坑在于:字符串里的锚点和你以为的周期可能不是一回事。

weekly = df.resample("W-MON", closed="left", label="left").agg( {"price": "last", "volume": "sum"} ) monthly = df.resample("M", closed="right", label="right").agg( {"price": "last", "volume": "sum"} ) quarterly = df.resample("Q", closed="right", label="right").agg( {"price": "last", "volume": "sum"} )

W-MON表示桶边界对齐到周一,做出来的周线每个桶从周一开始,适合业务上「自然周」的定义。M是日历月末,BM是工作日月末;Q是季度末,QS是季度初。M 和 Q 的默认 closed 是 right,跟 D 和 H 不一样,所以我在这里都显式写出来。

M和MS的区别最容易搞混。M是把数据聚合到以月末为边界的桶,标签是月末;MS才是从月初开始切。对日内数据做月线时,M的第一根桶可能只有月末最后一天的行情,如果没意识到这一点,看到第一根月线量特别小就会怀疑数据坏了。

做特征工程时另一个常见套路是重采样之后接 ewm。比如日线降采样完,对收盘价做指数加权平均生成平滑曲线,ewm的参数span、adjust在不同 pandas 版本里的默认行为有差异,用之前先打印一列对比一下再往下送。

4. 升采样是另一条路:asfreq、填充与插值

4.1 asfreq 与 resample 的关系:先对齐网格,再决定空位怎么办

升采样是把低频数据变高频,比如日线补成小时线。原始数据只有一个点,高频网格上其它位置都是空的,所以升采样本质上只有两件事:对齐网格、填空。resample 本身只建好空桶,不填值,真正填值要靠后面的方法。

daily = pd.DataFrame( {"close": [10.0, 11.0, 12.0, 13.0]}, index=pd.date_range("2024-01-01", periods=4, freq="D"), ) # asfreq 只对齐网格,不填值,结果全是 NaN print(daily.resample("H").asfreq().head(26)) # ffill 用前一个非空值向后填充 print(daily.resample("H").ffill().head(26))

asfreq()是「只对齐、不填充」的升采样,返回的序列里全是 NaN。它最大的用途是检查数据完整性:看看某一天到底缺了哪些小时。resample("H").ffill()则是把 1 月 1 日的收盘价 10 一直带到 1 月 2 日 0 点,中间所有小时都是 10。升采样时resample("H").last()和asfreq()在大多数情况下效果相同,因为没有桶内多个点可以取,但语义上还是用 asfreq 表达「对齐」更清晰。

4.2 ffill、bfill、nearest 在业务场景里的正确用法

填充方法的选择要看业务含义,不是哪个顺眼用哪个。

hourly = daily.resample("H") g1 = hourly.ffill() g2 = hourly.bfill() g3 = hourly.nearest()

行情快照用ffill最合理:在下一个事件发生之前,价格被认为维持上一个值,这符合「最新报价」的业务语义。库存盘点数据用bfill更合适,因为盘点动作发生后要往前回填。nearest适合缺口两侧都有值、且缺口中间的业务含义模糊时,取时间更近的一侧。

ffill的 limit 参数很容易算错。一天的数据升到小时,ffill(limit=23)刚好能把当天剩下的 23 个小时填满;但如果写成limit=3,一天里大部分时间还是空的。limit 是按新频率的格子数算的,不是按原来的天数算的,这是升采样场景里最经典的翻车点。

注意:升采样后一定要先isna().sum()看一眼还有多少空位。缺口数量和你预期不符,往往是 limit 或者频率写错了,这时候继续往下传数据,下游模型拿到一堆 NaN 根本不知道原因。

4.3 interpolate 平滑填充:什么时候可以用、什么时候别用

interpolate是升采样里看着最省事的方案,一行代码就能把空位填成平滑的曲线,但它有明确的适用边界。

smooth = daily.resample("H").interpolate(method="time") print(smooth.head(5))

method="time"会按时间间隔比例插值,两个相邻日线之间插出来的是一条直线上的点。温度、水位、浓度这类连续性传感器数据用这个非常合适,因为物理量本身就是连续变化的。价格、成交量、事件计数这类带跳变的数据别用,插值会在两根 K 线之间造出一个不存在的成交价,这种假价格进了策略回测就是血泪教训。

还有一点,DataFrame 的interpolate默认逐列插值。如果你 resample 的对象是多列 DataFrame,一定要先确认哪些列值得插,哪些列应该保持 NaN。比如volume这种计数型列被插出一个带小数的成交量,下游聚合时就会报警告,或者更糟,悄悄算出错误结果。

5. 避坑排查:resample 最容易翻车的五个细节

这一章写的都是实际用过才能踩出来的细节。每条都按「现象 → 原因 → 解决」来讲,照着排查比重新看一遍文档快得多。

5.1 时间列没转成时间索引,一切白干

现象:df.resample("D")直接报TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex, but got an instance of 'RangeIndex'。从 Excel 读进来的表,时间列是字符串,索引是 0 到 N 的数字。

原因:resample 只认时间类型的索引,它需要在索引上切频率桶。索引不是时间类型,第一步就过不去。

解决:先把时间列用pd.to_datetime转成 datetime64 类型,再set_index。format 参数要写上,errors="coerce"处理脏值。从 Excel 读文件时时间列经常带毫秒或者不统一的格式,用 format 明确告诉 pandas 怎么解析,能少踩很多隐蔽的坑。这一步本质就是数据清洗和数据类型转换,别跳过。

5.2 非数值列在 agg 里悄悄消失

现象:df.resample("D").sum()的结果里少了几列,object 类型的列不见了。

原因:resample 聚合默认只对数值列生效,object 列直接被忽略。更隐蔽的情况是列名和函数名冲突:数据里有一列叫count,df.resample("D").count()返回的是每个桶的行数,而不是对count列做计数。

解决:用字典显式指定每一列的聚合函数,列名列全,宁多勿少。如果确实想把所有数值列都 sum,先用select_dtypes过滤出数值列再 resample,这样代码意图也清楚。

5.3 closed 和 label 在升采样时不生效,这不是 bug

现象:df.resample("H", closed="left").asfreq()和df.resample("H", closed="right").asfreq()输出完全一样,改了跟没改一样。

原因:升采样只是把低频数据对齐到更密的时间网格,不存在「切桶」这一步,closed 和 label 参数只在降采样时有意义。文档里写的是这两个参数只能用于降采样,但代码不会因此而报错,所以很多人在这里浪费半天。

解决:升采样直接按上一章的方式处理,用 asfreq、ffill、bfill、interpolate,不需要纠结 closed。如果一定要调整对齐方式,用 origin 改锚点,而不是动 closed。

5.4 多级索引和时区索引直接翻车

现象:DataFrame 被set_index(["symbol", "time"])之后调resample("D"),报错信息里出现 MultiIndex 的字样;或者时区 aware 的索引在 resample 之后时间标签整体偏了 8 个小时。

原因:resample 要求索引是单一时间索引。多级索引里它不知道该按哪一级切桶;时区索引如果在 resample 时没指定 tz 参数,桶边界按 0 时区切,本地早上 9 点的数据会被标到前一天或者后一天。

解决:多级索引先用reset_index("symbol")把其它维度挪到列里,保留单独时间列做索引。时区数据先tz_convert到业务时区再 resample,或者在 resample 里显式传tz参数。多标的数据用groupby("symbol").resample(...)更干净,这个放最后一章讲。

5.5 09:30:00 的整点数据被归到 09:25 的桶

现象:09:30:00 那一秒的成交数据,在 5min 重采样后出现在标签为 09:25 的桶里,开盘第一根 K 线看起来多了一个不该有的点。

原因:这是 closed 和 label 搭配错位造成的。closed="right"时,5min 的桶是(09:25, 09:30],09:30:00 正好落在右边界上,属于这个桶;如果此时label="left",桶标签取左边界,显示为 09:25。数据本身是 09:30 的,却被打上了 09:25 的标签。

解决:closed 和 label 要配套使用,closed="right"配label="right",或者都用 left。对行情数据,推荐统一用closed="right"加label="right",这样整点数据归到整点标签。改完配置之后先打印 head(3) 对一下索引,确认边界符合业务预期再全量跑。

6. 进阶:自定义交易日历和分组重采样

6.1 用 CustomBusinessDay 对齐真实交易日

频率字符串表达不了节假日。A 股周一不开盘的日子,resample("B")依然会当成工作日处理,这就是节假日日历的场景。

from pandas.tseries.offsets import CustomBusinessDay holidays = [ "2024-01-01", "2024-02-12", "2024-02-13", "2024-04-04", "2024-05-01", "2024-06-10", "2024-09-17", "2024-10-01", ] cn_bday = CustomBusinessDay(weekmask="Mon Tue Fri", holidays=holidays) result = df.resample(cn_bday).agg({"price": "last", "volume": "sum"})

CustomBusinessDay用 weekmask 指定工作日,holidays 列表跳过指定日期。注意这里只是示例的节假日列表,真实生产环境要维护完整的交易所日历,这个列表千万别拍脑袋写。freq 字符串做不到的事情,DateOffset 对象可以做到,这是升到进阶环节最值得掌握的一个能力。

6.2 groupby 后再 resample:多标的数据统一处理

手里有多个标的的行情时,正确姿势是先 groupby 再 resample,而不是循环里对每个标的单独处理。

multi = df.assign(symbol=["A"] * 15 + ["B"] * 15) result = ( multi.groupby("symbol") .resample("30min") .agg({"price": "last", "volume": "sum"}) ) print(result.head())

groupby 之后再 resample,每个组都会按同一套规则切桶,返回的结果是MultiIndex,第一级是 symbol,第二级是时间。要落库或者继续加工,记得reset_index()。symbol 要放在列里而不是索引里,索引里如果还有别的维度,就会触发上一章说的多级索引问题。

前两年做回测时我在 label 上栽过一次:收盘那一刻的数据因为边界没对齐被算进了下一根 K 线,整个策略信号晚了一天。那之后我养成一个习惯,任何 resample 结果先打印 head(3) 对一下索引,再往下游送数据。后来所有用到 resample 的管道,我都先写一个最小样例把边界验证清楚再做全量。这个习惯帮我省下了很多次返工,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询