Pandas数据变频与插值:升采样降采样实战指南
发布时间:2026/10/14 7:00:10 锦皓数字建站

数据变频这件事说白了就是把时间序列从一种采样频率换成另一种采样频率。我最早接触它是在处理某传感器采集的振动信号设备每10ms采一个点我要跟另一套按秒记录的工艺参数做联动分析两边频率对不上什么模型都跑不了。后来做量化回测、做用户行为分析也反复遇到同样的问题——日线转周线简单周线转日线就麻烦更别提那种把5分钟数据掰成1分钟的需求。多数教程会直接甩一个resample()函数给你但实际落地时你会发现变频远不止一个API的事什么时候该聚合、什么时候该插值、线性插值到底在处理哪些缝隙这些才是真正决定结果能不能用的地方。这篇文章我打算从概念、API选型、插值原理、完整案例到边界情况把我这些年攒下来的实操经验完整梳理一遍。适合刚接触时间序列处理的开发者也适合那些已经在用Pandas但总在升采样时被NaN折磨的进阶读者。不需要你有太深的基础但最好对DataFrame的基本操作有点印象。1. 变频与插值为什么总是成对出现1.1 变频的本质时间轴不变采样点变了先理清楚一个概念。数据变频Resampling改的不是数据的数值而是数据在时间轴上的站位密度。你有100个数据点分布在一天里变频之后还是这一天但点变成了1440个或者变成24个。频率变了但底层事件本身没有变。这就会产生一个天然的矛盾高频转低频是减法低频转高频是加法。高频转低频比如秒级数据转分钟级你有一堆点可以挑/可以算聚合出每个分钟的代表值就行——取平均、取最大值、取每分钟最后一笔这些都叫聚合。而低频转高频比如一天一个值要转成一小时一个值中间的23个小时本来就没有任何观测记录你必须在什么都没有的地方凭空造出值来。这个造值的过程就落到了插值头上。所以你在任何讲变频的教程里都会看到插值不是因为它们长得像而是因为升采样这件事本身就只能靠插值或者填充来解决。插值是升采样的必选项变频是插值出现的典型场景两者天然绑在一起。1.2 现实场景里什么时候会触发必须变频我整理了几个我实际接触过的场景可能比教科书上的例子更贴近真实需求多源数据对齐某IoT项目里有温度传感器每10秒一条、湿度传感器每30秒一条、电压记录每分钟一条要算联合指标就必须先统一到同一个频率上。这里面必然有低频数据要升采样。金融K线周期转换5分钟K线转15分钟K线是聚合15分钟转5分钟就是插值。后者在量化回测里经常被用来构造如果当时有实时数据会怎样的假设序列。特征工程需要固定频率很多时序模型要求输入是等间隔的原始数据却经常漏采、乱时标。你要么删点要么用插值把缺失的格子填齐。图表绘制与前端展示前端图表库对稀疏数据支持很差曲线锯齿严重。事先做一遍升采样插值视觉上立刻平滑很多交互性能也更稳。这些场景的共性是什么它们都不是为了插值而插值而是要先改变数据的频率布局再让下游逻辑能正常工作。理解了这一层你就明白为什么我不能直接甩给你一个函数然后说用吧——你得先判断自己做的是降采样还是升采样这决定了后面完全不同的两套处理方式。1.3 降采样与升采样的分水岭聚合 vs 填充这是整个变频操作里最重要的判断我在接手任何一批时序数据时第一件事永远是问自己目标频率是比当前频率高还是低降采样Downsampling数据变稀疏要做的是信息浓缩。5分钟K线转30分钟K线每一根30分钟K线背后有6根5分钟K线可依Open取第一根的开盘Close取最后一根的收盘High取区间最大值Low取区间最小值Volume直接求和。这是有据可依的不需要任何插值。升采样Upsampling数据变稠密要做的是无中生有。每天一个销售数字要变成每小时一个某天凌晨2点到底卖了多少钱没有记录就是没有记录。你只能用某种合理假设把这个空填上假设前后两天平滑过渡线性插值假设这一天内每个小时都一样前向填充或者假设成前后两天的平均值。注意这永远是假设不是事实。这个区分之所以重要是因为很多人栽在同一个地方把降采样当成升采样处理或者反过来。降采样你用插值等于凭空捏造了一组聚合信息结果下游统计全部失真升采样你用聚合函数Pandas会直接给你满屏NaN因为升采样根本聚合不出东西来。记住这句话降采样靠聚合升采样靠插值/填充两者不要混。后面的所有代码都是在为这两句话做注脚。2. 用Pandas做变频asfreq、resample、reindex的选型差异2.1 三个API各管哪一段Pandas里能改变数据频率的不止一个函数我见过很多人只认识resample()遇到复杂一点的需求就卡住。其实拆开看三个函数各管一摊函数定位降采样升采样典型场景asfreq()纯频率转换只改时间轴刻度直接丢弃非目标频率的点新增NaN行不做任何填充把日线转成周线只用每周最后一个交易日resample()基于时间分组的重采样用agg()指定聚合逻辑自动插入NaN行需配合填充/插值日线求月线均值、5分钟转15分钟K线reindex()按新索引重新排列对齐已有索引缺失置NaN指定methodffill可填充数据对齐、频率变化后补索引用一句话概括asfreq()是只改钟表刻度resample()是改了钟表刻度还要算一笔账reindex()是以某个索引为基准重新排队。变频最常用的是resample()但asfreq()在做日历对齐时有奇效别把它忘了。2.2 降采样的聚合姿势先看最规矩的降采样。假设你手里有一份每5分钟一条的模拟数据要转成15分钟最稳妥的写法是这样import pandas as pd import numpy as np # 构造一份5分钟间隔的模拟数据 idx pd.date_range(2025-01-01, periods288, freq5min) df pd.DataFrame({value: np.random.randn(288).cumsum()}, indexidx) # 降采样到15分钟分别聚合 resampled df.resample(15min).agg({ value: mean # 每个15分钟窗口内的均值 })如果这条数据是K线你想要的不是均值而是每根目标K线的Open/High/Low/Close/VolumePandas里有个针对金融K线的现成写死函数叫ohlc()但更可控的写法是用.agg()把所有逻辑一次写完df_resampled df.resample(15min).agg({ open: first, high: max, low: min, close: last, volume: sum })这里每个字段名都对应原始数据里已有的列名。第一次用的时候容易把agg的字典键写成你想输出的名字其实是错的——键必须是原始DataFrame里的列名输出索引还是老列名。想要自定义输出列名可以用NamedAgg。降采样时最容易忽略的是窗口对齐方式。resample()默认是左闭右开标签取窗口的左侧时间点labelright可以改成右端点。比如5分钟数据转15分钟第一个窗口覆盖00:00到00:15含起点不含终点如果业务上习惯每个时段结束时刻打上标签记得加labelright。这个细节直接决定你下游画图时横轴刻度偏一个格子排查起来很隐蔽。2.3 升采样的默认行为和两个常见手法升采样就没降采样这么舒服了。df.resample(1min)直接作用于比原频率更细的时间轴时Pandas能做的只是把每一分钟的位置铺好然后在没有原始数据的位置填入NaNidx pd.date_range(2025-01-01 00:00, periods3, freq1h) df pd.DataFrame({value: [10, 20, 15]}, indexidx) # 从小时升采样到分钟 df_1min df.resample(1min).asfreq() print(df_1min.head(10)) # output: # value # 2025-01-01 00:00:00 10.0 # 2025-01-01 00:01:00 NaN # 2025-01-01 00:02:00 NaN # ...浩浩荡荡的NaN填满了每一个新位置。此时你有两条路前向填充ffill把当前时刻的值一路带到下一个原始点之前。适合这个值在这段时间内一直有效的业务比如电力调度里的计划值、库存快照。插值interpolate把相邻两个原始点连成直线中间每个分钟点按比例取值。适合变量在这段时间内平滑变化的业务比如温度、液位、收益率曲线。一个很容易踩的坑是直接在resample()里传methodffill这个API在Pandas 2.x里已经不支持了。当年旧版本允许resample(1min).ffill()新版把它挪到了填充阶段去做间接也逼迫用户明确先造NaN再填充的心理模型也算好事。正确的做法是# 方式一升采样后显式填充 df_ffill df.resample(1min).ffill() # 前向填充 df_interp df.resample(1min).interpolate() # 线性插值 # 方式二asfreq之后接fillna/interpolate df_ffill_v2 df.asfreq(1min).ffill() df_interp_v2 df.asfreq(1min).interpolate()两条路殊途同归。区别在于resample()可以顺带先做点分组逻辑再填充asfreq()则纯粹的只是改变刻度。我个人习惯是单纯升采样对齐用asfreq()因为它语义更干净出问题容易定位如果同时还要处理多个列的不同聚合/填充策略再上resample()。2.4 什么时候需要reindex而不是resampleresample()有个隐含假设你希望按固定的频率规则重新切分时间轴。但有些场景下你手里的目标时间轴并不是规则的设备每天只在早上9点到下午5点工作我要把全天数据对齐到这台设备的实际日志时间点上。 这时候resample(xxx)根本排不出你想要的轴因为目标轴本身是不规则的。reindex()就是为了这种对齐到指定索引而存在的。你可以先自己构建一个目标时间轴不管规则不规则然后让数据整体对齐过来配合method参数控制填充方式# 假设目标轴是不规则时间戳 target_index pd.to_datetime( [2025-01-01 08:30, 2025-01-01 08:45, 2025-01-01 12:10] ) df_aligned df.reindex(target_index, methodffill) df_aligned_interp df.reindex(target_index).interpolate(methodtime)注意reindex(index, methodffill)的填充逻辑是按索引顺序找前一个有效值跟时间间隔没有任何关系哪怕前一个有效值在10个小时之前它也照填不误。所以在不规则时间轴上升采样我这边的经验是先reindex再interpolate(methodtime)后者会按照时间戳的实际间距计算插值比例而不是把行号当作等间距这个差异下一节会展开讲。3. 线性插值的原理与手写实现从公式到Pandas的interpolate3.1 线性插值到底在算什么线性插值的数学定义很简单它是在两个已知点之间拉一条直线然后在这条直线上取你需要的中间值。假设已知点A的时间和值是(t₀, y₀)点B是(t₁, y₁)你要算t时刻的值公式长这样y y₀ (y₁ - y₀) × (t - t₀) / (t₁ - t₀)一句话解释从y₀出发朝y₁方向走走多远呢走「(t - t₀)占(t₁ - t₀)比例」那么远。这个比例永远介于0和1之间所以插值出来的值也永远在y₀和y₁之间。这就是为什么线性插值最稳——它不会跑出已知区间的范围不像多项式插值那样可能甩出离谱的震荡。我把这个公式拆成人话任何一个中间点都能从前一个点和后一个点按距离掰比例得到。时间上距离前一个点越近值就越像前一个点距离后一个点越近就越像后一个点。就这么朴素。3.2 不用Pandas用纯Python写一个理解最佳的方式永远是亲手实现一遍。下面这个函数输入一个已知点的数组时间和值再给一个目标时间返回插值后的结果def linear_interpolate(times, values, target_time): times: 已知时间戳列表升序 values: 已知值列表 target_time: 目标时间戳 if target_time times[0]: return values[0] if target_time times[-1]: return values[-1] # 找到目标时间所在的区间 [i, i1] for i in range(len(times) - 1): if times[i] target_time times[i 1]: t0, t1 times[i], times[i 1] y0, y1 values[i], values[i 1] ratio (target_time - t0) / (t1 - t0) return y0 (y1 - y0) * ratio raise ValueError(目标时间不在范围内) # 示例已知1点和3点的值插值出2点的结果 times [1, 3] values [10, 30] print(linear_interpolate(times, values, 2)) # 输出 20.0这段代码没用任何第三方库逻辑也简单得不能再简单先找目标点在哪个区间然后按比例掰值。如果你处理的是等间隔序列ratio甚至可以简化成固定的小数——比如5分钟转1分钟每个插值点间距相等比例都是0.2、0.4、0.6这样规律变化。不过实际工程里没人手动写这个循环因为numpy已经给你备好了np.interp()import numpy as np target_times np.array([1.5, 2.0, 2.5]) result np.interp(target_times, times, values) # array([15., 20., 25.])注意np.interp()有个很要命的默认行为目标值在已知范围之外时它会直接返回边界值跟上面我手写版本的 times[0]和 times[-1]逻辑一致。这种外推即钳位的行为大多数业务场景下是安全的——它至少不会让你莫名其妙插出个负数温度来。3.3 Pandas的interpolate三种模式linear、index、timePandas的DataFrame.interpolate()默认就是线性插值但如果你细心翻文档会发现它有两个容易搞混的关键参数。这三个模式解决的是同一个问题两个已知点之间间隔多远这个距离用什么尺度来衡量。methodlinear无视索引的实际数值把行号当成等间距使用。哪怕你的时间戳是00:00、00:10、00:30这种不均匀间隔它照样按一行等于一个单位来算结果就是00:20的值会被算成00:10和00:30的中间值而实际上00:20离00:10更近应该是前者的三分之二偏向。methodindex用索引的实际数值作为距离。索引是时间戳就按时间戳的真实间隔算比例解决了上面不均匀的问题。methodtime在index的基础上专门优化了时间戳场景语义最明确。当你的索引是DatetimeIndex或TimedeltaIndex时我建议直接用time。看一段实际对比idx pd.to_datetime([2025-01-01 00:00, 2025-01-01 00:10, 2025-01-01 00:30]) df pd.DataFrame({value: [0, 100]}, indexidx) df_linear df.reindex(pd.date_range(2025-01-01 00:00, periods4, freq10min)) df_linear[linear] df_linear[value].interpolate(methodlinear) df_linear[time] df_linear[value].interpolate(methodtime) print(df_linear) # value linear time # 2025-01-01 00:00:00 0.0 0.0 0.0 # 2025-01-01 00:10:00 NaN 50.0 NaN # 已知点本身是NaN但插值回合了 # 2025-01-01 00:20:00 NaN 50.0 33.3 # 2025-01-01 00:30:00 NaN 100.0 100.0看00:20这一行linear模式按行号等距认为00:20就是00:10到00:30的正中间给了50time模式按真实时间戳间隔算00:20只走了前一个点到后一个点的三分之一路程所以给了33.3这个才是符合物理直觉的结果。只要你的时间戳不保证严格等间隔线性插值请一律用methodtime或methodindex不要用默认值。3.4 线性插值解决不了的三种情况线性插值虽然覆盖面广、实现简单但它不是万能药。我踩过这几类坑提出来供你避雷突变场景。用户点击量、秒杀订单量这种带尖峰的数据在相邻两个点之间用直线连起来会把本来应该突兀的跳变削成一条缓坡。这时候更适合前向填充或者分段处理先识别突变点再单独填充。边界外推。数据两端没有邻居可参考interpolate()默认对首尾的NaN无能为力。开头缺两个值它就给你留两行NaN。解决办法是加limit_directionboth但注意这本质是外推只在趋势性很强的场景下用。周期项。一天内的温度曲线有昼夜节律用线性插值把凌晨和正午连起来中间结果明显失真。正确的姿势是用时间特征做回归或者用spline插值但那就脱离本文主题了。这些情况不需要一开始就全考虑但至少在你决定用插值之前问一句我这条数据在相邻两点之间真的能近似当成直线吗能就用线性不能换思路。4. 一个完整的变频实战5分钟数据转1分钟逐个坑走一遍4.1 案例背景与目标定义我以一个模拟的行情数据为例来走完整流程。假设有一个5分钟粒度的时间序列字段有时间戳、价格、成交量一共288条正好一天。现在需求是转成1分钟粒度价格字段用线性插值成交量字段用前向填充因为成交量在这5分钟内应该视为累计而非瞬时变化时间轴要完整覆盖这一天。第一步当然是造数据和观察原始分布idx_5min pd.date_range(2025-01-02 09:30, periods48, freq5min) df pd.DataFrame({ price: np.random.randn(48).cumsum() 100, volume: np.random.randint(100, 500, 48) }, indexidx_5min)price是收盘价volume是每根5分钟K线的成交量。目标把48根5分钟K线变成240根1分钟K线。4.2 第一步升采样铺时间轴升采样的关键操作是先把时间轴切细。直接用asfreq(1min)把原数据映射到分钟轴上没有原始数据的分钟全部置NaNdf_1min df.asfreq(1min) print(df_1min.isna().sum()) # price 192 # volume 192看到没原来48条有效数据铺到240个位置上自然有192个NaN。此时price和volume都是192个NaN但我们要对它们采取不同策略——这正好验证了前面的观点升采样一定伴随填充但填充策略可以按列而异。4.3 第二步按列差异化填充price字段是平滑变化的连续变量用interpolate(methodtime)让插值比例跟真实时间间隔挂钩volume字段是每个5分钟周期的累计值在5分钟内部没有新增信息用ffill()把本周期数值填满整段df_1min[price_interp] df_1min[price].interpolate(methodtime) df_1min[volume_ffill] df_1min[volume].ffill()这里有个容易忽略的细节ffill()默认是按索引顺序往前找值它不关心时间间隔长短。也就是说在10:00到10:05之间每一分钟拿到的都是10:00这条5分钟数据的值这是符合业务直觉的。然后你会注意到最后一行如果恰好是NaN比如原始最后一条的时间不落在分钟轴上ffill()是补不上的interpolate(methodtime)对尾部的缺失也无能为力。此时可以单独处理尾部补一个limit_directionboth或者直接用最后有效值填充。我通常的做法是先看尾部是不是真的有NaN有就手动补最后一条或者用bfill()兜底。4.4 第三步检查插值结果是否合理光跑通还不够你得检查插值质量。我的检查套路三连数值范围检查插值出来的价格应该在原始最大最小值之间如果出现越界除了外推说明参数用错了。连续性检查画一条折线看看有没有断点或者跳变。线性插值的结果应该处处连续首尾相接。样本对照随机抽几个原始5分钟点的位置确认该时刻的值没有被插值改动过。原始点处的值在asfreq()后被保留但如果你执行了interpolate()且该点前后也有NaNPandas会连同已知点一起覆盖——实测中它不会但防御性检查不亏。一个快速的质量检验代码import matplotlib.pyplot as plt fig, ax plt.subplots(2, 1, figsize(10, 6)) ax[0].plot(df_1min.index, df_1min[price_interp], label1min interp) ax[0].plot(df_1min.index, df_1min[price], o, label5min raw) ax[1].plot(df_1min.index, df_1min[volume_ffill], labelvolume ffill) plt.legend() plt.show()如果你的图里插值曲线和原始点完全贴合、没有突兀拐弯这一步就算过了。这里再次强调一个实操心得不要相信interpolate()的默认参数一定要传method时间索引一定传methodtime。我见过太多人在等间隔数据上测试没问题换到真实业务时间戳上全盘错位就是栽在这一点。4.5 完整流程封装成函数把上面流程整理成一个可复用的函数以后遇到类似的变频需求直接抄def upsample_with_strategy(df, freq, interp_colsNone, ffill_colsNone): df: 原始DataFrame索引为DatetimeIndex freq: 目标频率如 1min interp_cols: 需要线性插值的列名列表 ffill_cols: 需要前向填充的列名列表 df_out df.resample(freq).asfreq() if interp_cols: for col in interp_cols: df_out[col] df_out[col].interpolate(methodtime) if ffill_cols: for col in ffill_cols: df_out[col] df_out[col].ffill() return df_out这个函数有个隐含要求传入的DataFrame列已经分好类。实际项目中字段命名规范一点这函数能直接用很久。它不追求一条语句的极简但胜在语义清楚——你要什么策略列里一目了然。5. 边界情况与填坑记录那些让插值结果悄悄变味儿的细节5.1 时间戳重复插值前不清理结果全是鬼打架真实数据几乎没有不脏的。最常见的问题之一时间戳重复。可能是采集端重传可能是数据合并时没有去重当index有重复值时resample()和interpolate()的行为就会变得难以捉摸——resample()默认对重复索引会报错或聚合interpolate()则可能因为同一个位置有两个值而直接忽略其中一个。我的处理顺序永远是先去重、再排序、再变频。df df[~df.index.duplicated(keepfirst)].sort_index()至于重复时保留哪一条没有标准答案——业务上谁后到谁覆盖先用keeplast想保留最早的一次就用keepfirst我一般看数据源哪边更可信。但无论如何带着重复索引做插值等于让Pandas在这个点到底该用哪个值上替你掷骰子这种不确定性绝对不能有。5.2 时间戳乱序resample之前不排序会怎样interpolate()本身不会因为索引乱序而报错但它会把乱序当成先后关系来处理结果就是在看似有逻辑的NaN填充里掺杂了完全无意义的值。我踩过一次某设备日志的时间戳是本地时间混着UTC时间写入的整体趋势是递增但中间混进来几条倒退记录插值结果直接出现了一条时光倒流的曲线画出来让人头皮发麻。规矩做法非常土但非常有效df df.sort_index()排序之后再插值结果就正常了。这里的建议是把时间索引当数据预处理的第一道关卡宁可多做一次去重排序也不要省。它不是变频逻辑的一部分但它是变频结果可信的地基。5.3 首尾缺失interpolate默认救不了你前面提到interpolate()对数据首尾的缺失值无能为力这其实是Pandas的默认策略它只会在已知点之间做填充不向已知范围外推。这个设计很安全但业务上很麻烦——升采样之后数据头部和尾部往往各有一小段NaN。处理方式有两种取决于业务意图用limit_directionboth允许向两端外推得到一条从第一个已知点延伸到最后一个已知点的直线。趋势型数据比如缓慢上升的温度可以用。用bfill()或ffill()单独处理头尾。头部缺失用bfill()拿第一个有效值填尾部缺失用ffill()拿最后一个有效值填。这种边界钳位方式最保守也最不容易被质疑。我的选择标准很简单看下游算法对这些边界值敏不敏感。如果只是画图用limit_directionboth省事如果要喂给差分、切线等对数值敏感的计算一律用钳位别外推。外推出去的假数据可能离真实值很远却会被模型当成事实看待风险太大。5.4 频率别名与工作日历的坑Pandas的频率别名看着简单实际玩起来水很深。D是自然日B是工作日H是小时W是周日为一周的第一天M是日历月末BM是工作日月末——任何一个用错都会让你明明跑通了却结果莫名其妙地错位。举个例子日线转周线df.resample(W).last() # 按周日为周的起点取每周日或每月最后一个周日的值 df.resample(W-FRI).last() # 以周五为周起点更贴合A股/美股周线交易数据的周线通常锚定星期几用W-FRI比W更符合业务直觉。类似地分钟级还有每半小时但只在交易时段内这种需求单纯靠频率别名做不到得配合时间的布尔掩码切片之后再做resample()这些我都归为频率别名背后的业务日历问题。我的建议是动手前先把业务日历翻译成频率别名拿不准就在一个小的可视化样例上验一次。与其指望一次写对不如相信验证。5.5 性能大数据量下interpolate会不会很慢最后说说性能。我在处理某传感器项目时原始数据是连续几个月每毫秒一条直接resample(1s)加interpolate(methodtime)跑了将近两分钟。这时候优化思路不是换插值算法而是分块。def chunked_upsample(df, freq, chunk_days7): chunks [] for _, chunk in df.groupby(pd.Grouper(freqf{chunk_days}D)): chunks.append( chunk.resample(freq).asfreq().interpolate(methodtime) ) return pd.concat(chunks)按周分块之后每次处理的量级从几个月降到一周速度直接快了一个数量级。中间需要注意的是分块边界处的插值会因为缺少跨块的邻居点而在边界产生微小误差但只要你的频率不是特别粗糙比如两天一条转一小时一条误差几乎不可见。这里我日常的经验准则是数据量超过500万行就不要在一个巨大DataFrame上做一次性变频了。拆成时间窗逐段处理再拼接又是另一种稳定可靠。结尾的几句体会做了一段时间的数据处理后我最大的感受是——变频和插值这个组合说起来人人都知道但真正做好的人并不多。诀窍不在API记得多熟而在你愿不愿意在动手前多问自己几个问题这次是升还是降每一列数据在缺失位置上的合理假设是什么时间戳干净吗边界怎么处理这四个问题想清楚了写代码反而只需要十分钟。我建议你把自己最常用的变频流程封装成一个小工具函数参数暴露列级别的填充策略下次遇到新数据直接套——实测下来这套工作流能帮你省掉大量重复劳动也让团队里的其他人拿到手就能用。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。