
DataFrame 的时间序列处理我前后踩过三轮坑才算真正用顺。第一轮是把时间列当普通字符串排序结果乱序数据喂进模型指标飘得没法复现第二轮是重采样窗口边界没对齐日汇总和原始分钟数据对不上账排查到半夜第三轮最狠构造的特征里混进了未来信息离线评估漂亮得离谱实际跑起来直接失效。这三个坑基本覆盖了时间序列 DataFrame 的绝大多数陷阱也正好说明了为什么这一块必须单独拎出来讲。它是 DataFrame 系列的收尾篇也是衔接机器学习前置技术栈的关键一环——前面讲的索引、切片、合并、聚合本质都是把数据摆整齐而时间序列这一篇要解决的是让数据带着时间语义动起来。如果你正在做设备采集分析、业务指标监控、销量预测或者准备把一张带时间戳的表转成模型能吃的特征矩阵这篇的内容基本可以照着抄。下文所有代码都基于常规实践整理环境是 Python 3.10 以上配 Pandas 2.x2.x 和 1.x 在部分时间序列行为上有差异我会在涉及的地方标出来。1. 时间序列 DataFrame 的底层逻辑与设计取舍1.1 为什么时间列不能只当普通列用很多人第一次处理时间数据的直觉是时间不就是一列字符串吗排序、筛选、分组照做就行。这个做法在数据量小、需求单纯的时候确实能跑一旦涉及按天汇总、取最近七天均值、补齐缺失的分钟点这类操作就会立刻暴露出问题。原因在于 Pandas 的时间序列能力并不是挂在列上的而是挂在索引上的——DatetimeIndex是一个带有频率语义的特殊索引对象它能回答下一个时间点是谁这种普通列回答不了的问题。你写df.resample(D)Pandas 需要知道当前索引的粒度、有没有空洞、边界怎么取这些都依赖索引是DatetimeIndex这个前提。把时间留在普通列里resample直接报错rolling的时间窗口写法也用不了只能退化成按行数滚动的近似方案精度和语义双输。我自己习惯的做法是原始数据读进来之后第一时间把时间列转成datetime64[ns]然后set_index提到索引位置再sort_index()排一次序。这三步做完后面所有操作才有意义。排序这一步特别容易被跳过因为大多数采集数据天然是顺序写入的看起来不用排。但只要中间有过补数、合并、多源拼接顺序就可能被打乱而resample、rolling、shift全都是顺序敏感的——顺序错了结果不会报错只会静默算错这是最难查的一类问题。1.2 频率语义freq 到底约束了什么freq这个参数经常被误解成数据实际的间隔其实它表达的是时间轴上的栅格规格。举个例子一张表里记录的是设备触发事件的时间戳间隔完全不规则你可以给它设freqNone默认也可以强行设成1min。设成1min之后Pandas 会认为这条时间轴是按分钟对齐的asfreq(1min)就会把不在整分钟上的点丢掉或者对齐行为完全不同。所以freq是一个声明不是一个检测——你声明了什么Pandas 就按什么处理声明错了它也不会提醒你。这个设计取舍背后其实很合理自动推断频率在数据有噪声时极不可靠与其猜错不如让使用者显式声明。代价就是我们必须自己确认数据的真实粒度。我常用的检查手段是把索引转成 Series 求差分然后看众数import pandas as pd gap df.index.to_series().diff().value_counts().head(5) print(gap)输出的前几名基本就反映了真实粒度。如果第一名是0 days 00:01:00且占比九成以上那freq1min就是安全的如果前几名分散得很厉害说明数据有大量缺失或者本身就是事件流这时候强行设freq只会给自己埋雷不如保持None用resample的时候再显式指定目标频率。这个习惯帮我省过很多次排查时间——频率声明错误导致的问题往往要等到下游聚合结果明显不合理才被发现。1.3 几种常见方案的横向对比时间序列的建模方式不止一种选错了后面全是返工。我把实际项目里用过的几种方案列个表方便对照自己的场景挑。方案时间位置适用场景主要优势主要代价普通列 手动分组普通列一次性报表、低频分析无需学习成本无法用 resample/时间窗口 rollingDatetimeIndex resample索引固定频率聚合、降采样语义清晰、代码短要求数据大致规整整数序号 时间列普通列不规则事件流、序列模型顺序可控、便于对齐时间语义要自己维护PeriodIndex索引按月/季等周期统计周期边界天然正确跨周期运算受限第三行那个方案值得多说一句。做序列模型比如基于滑动窗口的回归时我有时会故意不用DatetimeIndex而是保留一列整数seq当索引时间作为普通列挂着。原因是模型训练阶段更关心第 t 步到第 t-1 步的严格先后关系用整数序号可以避免时间空洞带来的隐式错位特征对齐也更直观。这不是说DatetimeIndex不好而是说工具要匹配阶段——探索分析和特征工程用时间索引喂模型前转成规整的二维矩阵。分清楚这个边界很多到底该用哪种的纠结就没了。2. 时间索引的建立与清洗脏数据的第一道闸2.1 to_datetime 的参数怎么选才不出事pd.to_datetime是入口函数也是最容易埋雷的地方。最常见的坑是不指定format让它自己猜。Pandas 的推断逻辑在混合格式下会退化成逐行解析速度慢几十倍不说遇到2024-03-01和01/03/2024混在一起的情况它可能把后者解析成 1 月 3 日——月日颠倒结果全错而且不报错。我在一个多源合并的表上吃过这个亏两个数据源一个用 ISO 格式一个用斜杠格式合并后时间线整体错位了半个月查了两天才定位到。所以我现在的固定写法是能确定格式就显式写format格式不统一就先按来源拆开分别转转完再合并。import pandas as pd raw pd.DataFrame({ ts: [2024-03-01 08:00:00, 2024-03-01 08:01:00, 2024-03-01 08:02:00], value: [1.2, 1.5, 1.1], }) raw[ts] pd.to_datetime(raw[ts], format%Y-%m-%d %H:%M:%S, errorsraise)errors参数也值得展开说。默认是raise遇到解析不了的值直接抛异常改成coerce会把坏值变成NaT流程能继续跑下去。我倾向于在探索阶段用raise逼自己把脏数据全部找出来只有在已经确认脏值占比极低、且能接受丢弃的场景下才用coerce。见过不少代码一上来就errorscoerce结果整列里有三成解析失败全变成NaT后续dropna一删样本少了一大截还没人察觉。另一类坑来自时间戳里的意外成分比如带毫秒、带时区偏移、带中文时分秒。带时区的字符串转出来是datetime64[ns, tz]和本地时间的不同列运算会报错需要统一tz_localize或tz_convert。跨时区采集的场景里我的建议是一律先转成 UTC 存着展示和业务判断的时候再转回本地偏移。这样算差值、算间隔时永远不会因为切换偏移量而出现一小时误差也避免了短暂偏移调整期内的重复或缺失时间点——这类问题在日志里极难发现表现就是某个小时的样本数莫名其妙多出或少掉一批。2.2 set_index、排序与重复时间戳的处理转完类型就可以提索引了。这里有个顺序细节先set_index再sort_index和先sort_values再set_index结果一样但后者更慢因为sort_values走的是普通列排序路径。直接排序索引效率更高df raw.set_index(ts).sort_index()真实数据里重复时间戳非常普遍尤其是多设备、多渠道写入同一张表的时候。重复索引会让resample的某些聚合口径变得微妙也会让reindex报错。处理方法要看业务含义如果是重复上报保留最后一条更合理后写覆盖先写如果是同一时刻不同维度那就不该合并应该加维度列做成MultiIndex。dup_cnt df.index.duplicated().sum() if dup_cnt: df df[~df.index.duplicated(keeplast)]keep取first、last、False三种False是把所有重复项全删掉慎用——它会连带丢掉本来有效的那条。我一般先统计重复数量占比很低千分之几才直接去重占比高的话说明数据源有问题得回头查采集链路不能靠去重掩过去。还有一个容易被忽略的点MultiIndex下的时间序列操作。当索引是(设备编号, 时间)两层时resample需要配合level参数指定在哪一层做df_multi df_multi.resample(1h, levelts).mean()level写错或者不写会得到完全不相关的结果或直接报错。这个用法在处理多序列数据多台设备、多个门店时几乎是必备的比循环groupby快一个量级我做过一次对比二十万行、200 个分组resample(level)比groupby().apply()快大约四倍。2.3 补齐时间轴reindex 的正确打开方式缺失时间点有两种处理哲学一种是没有就是没有保持稀疏一种是补齐栅格再填值保持规整。分析阶段我偏向后者因为整条时间轴完整之后rolling、diff、shift的语义才准确。补齐用reindex配合date_range比resample更可控因为它不会做任何聚合full_idx pd.date_range( startdf.index.min(), enddf.index.max(), freq1min, namets, ) df_full df.reindex(full_idx)namets这个参数别漏不写的话新索引没名字后面reset_index会得到index这个列名接下游容易对不上。补出来的空位是NaN怎么填取决于数据性质测量值可以前向填充ffill或线性插值计数类指标应该填 0比率类指标最好别乱填宁可标成缺失让模型自己处理。我见过把缺失的比率直接ffill二十多个点的做法等于人为制造了一段平坦的假数据模型学到的就是这个假规律评估阶段看起来还行实际预测时就会在同样的位置给出可疑的平稳输出。注意ffill一定要设limit否则长时间断流会被一个值填满整段看起来像真的连续观测。我的习惯是limit3最多补三个点超出的保持NaN交给下游丢弃或单独标记。3. 重采样与频率转换降采样和升采样的实操细节3.1 resample 的聚合字典与命名规范降采样高频转低频最常用的就是resample。基础写法很简单但真正用起来聚合方式往往不是一个函数能打发的——平均值、最大值、样本数、缺失率一次要算好几个指标。这时候用命名聚合Pandas 0.25 之后支持比传字典更清晰daily df.resample(D).agg( val_mean(value, mean), val_max(value, max), val_min(value, min), sample_cnt(value, count), missing_cnt(value, lambda s: s.isna().sum()), )(列名, 函数)这种元组写法直接产出扁平列名比agg({value: [mean,max]})出来的多级列名好处理得多。多级列名后续要droplevel或者手动拼名字很容易出错尤其是下游要按列名取特征的时候。这一点我在给模型准备特征表时深有体会——多级列名导成 CSV 再读回来列名会变成(value, mean)这样的元组字符串再想取某列就非常难受。所以从聚合那一步就把名字定干净后面省事。3.2 窗口边界closed 和 label 到底怎么影响结果resample里最容易算错的就是窗口边界。默认closedleft、labelleft意思是左闭右开、标签用左端点。举个具体例子resample(5min)在08:00到08:05之间默认口径包含08:00:00到08:04:59的数据时间标签记为08:00。这个默认值符合大多数人的直觉但当你要和另一套系统对齐时就必须显式确认——对方是左闭右开还是左开右闭标签打在区间起点还是终点差一点就是整体偏移。left_closed df.resample(5min, closedleft, labelleft).mean() right_closed df.resample(5min, closedright, labelright).mean()这两行结果看起来只差一个时间标签但实际数值分布完全不同因为落到每个桶里的原始点不一样。我在一次对账里就是因为这个差了一整个时间桶上游按右闭算、我按左闭算两边的日汇总在跨天时刻差了整整五分钟的数据量级不大但对不上账就是问题。还有一个参数origin在按天、按周聚合时很关键。默认originstart_day也就是按自然日切如果要按从某个时间点开始的 24 小时切就得传originstart或具体时间戳。做班次统计、按批次时长聚合的场景这个参数直接决定结果对不对。3.3 asfreq 和 resample 的差别别混着用这两个函数经常被当成等价物其实定位完全不同。resample是分组聚合必须给一个聚合方式或者默认取均值asfreq是重采样到新栅格只做取样不聚合多出来的点直接丢空出来的位置给NaN。做升采样低频转高频的时候我几乎只用asfreq因为升采样本来的语义就是放大栅格、保留原值、填空缺用resample反而会引入不必要的聚合逻辑。up df.asfreq(30s) up[value] up[value].interpolate(methodtime)interpolate(methodtime)这个写法值得单独说。普通的interpolate()是按位置线性插值methodtime是按时间距离加权。数据间隔不均匀的时候两者差别明显比如08:00值是 1、08:04值是 5中间08:01那个点按位置插值给 2等距假设按时间插值同样给 2看起来一样但如果中间点是08:03按位置给 3按时间给 4。显然按时间更符合物理意义。不规则采集的数据做插值一定要加methodtime否则时间间隔的信息白丢了。3.4 一个完整的分层聚合实战把上面这些串起来走一遍从分钟级原始数据到日级特征表的完整流程。假设手头是某设备采集项目导出的一份记录字段是时间和读数存在乱序、少量重复、部分时段断流。import numpy as np import pandas as pd # 1) 解析与索引 df (raw .assign(tslambda d: pd.to_datetime(d[ts], format%Y-%m-%d %H:%M:%S)) .set_index(ts) .sort_index()) # 2) 去重 df df[~df.index.duplicated(keeplast)] # 3) 补齐分钟栅格 full_idx pd.date_range(df.index.min(), df.index.max(), freq1min, namets) df df.reindex(full_idx) # 4) 短缺口前向填充长缺口保留 NaN df[value] df[value].ffill(limit3) # 5) 日级聚合 daily df.resample(D).agg( val_mean(value, mean), val_std(value, std), val_max(value, max), sample_cnt(value, count), missing_cnt(value, lambda s: int(s.isna().sum())), ) # 6) 派生指标 daily[coverage] daily[sample_cnt] / 1440 daily[range] daily[val_max] - daily[val_min]这里第 6 步的coverage覆盖率是我每次都会加的一列。它等于有效样本数除以理论样本数直接反映当天数据质量。覆盖率低于某个阈值的日期后面建模时我会单独标记甚至剔除——用覆盖率只有三成的天算出来的日均值噪声比信号大得多混进训练集只会拉低模型表现。这个指标没有任何技术难度但很多人的特征表里就是没有导致他们永远不知道自己的日均值里有多少天是残缺的。4. 滚动窗口与滞后特征机器学习前置的核心武器4.1 rolling 的窗口类型与 min_periods 陷阱rolling有两种窗口定义方式整数窗口按行数和时间窗口按时间跨度。按行数写rolling(7)就是前 7 行前提是数据已经按固定频率补齐否则7 行可能对应三天也可能对应七小时语义完全不同。时间窗口写rolling(7D)才是字面意思上的过去七天Pandas 内部按时间戳判断不看行数。做业务特征我强烈建议用时间窗口因为业务口径通常按自然时间定义最近七天而不是最近七条记录。df[ma_7d] df[value].rolling(7D, min_periods1).mean() df[std_7d] df[value].rolling(7D, min_periods3).std()min_periods这个参数是个大坑。默认值等于窗口长度意味着前六个点的滚动均值全是NaN——数据头部直接损失。设成min_periods1能让头部也算出值但早期的值是两三个点算出来的方差极大和后面的值不在一个尺度上。我的处理方式是折中设min_periods为窗口长度的一半左右早期那一段直接在下游截掉不用。这样既不会因为头部NaN导致整行被dropna删掉也避免了用两三个点冒充七天均值。窗口的center参数也要留意默认False表示窗口右端对齐当前点只用历史设True会让窗口居中等于用了未来数据。做特征工程时center必须是False一旦设成True你的滚动均值里就包含了未来信息离线指标会虚高。这个参数在画图平滑曲线时很好用在建模特征里是禁用项两种场景要分开记别写混。4.2 expanding 与 ewm长记忆特征的两条路线rolling是固定窗口expanding是从开头累积到当前点窗口长度一直变大。适合刻画累计表现和历史全量统计df[cum_mean] df[value].expanding(min_periods10).mean() df[cum_max] df[value].expanding(min_periods10).max()ewm是指数加权越近的点权重越大数学上等价于一个无限长但权重衰减的窗口。做有明显趋势和突变的指标比如流量、活跃度ewm通常比简单滚动均值跟得更紧滞后更小。关键是span和alpha的换算关系alpha 2 / (span 1)span7对应alpha0.25。我一般直接用span因为它和多少天的直觉对应得更直接。df[ewm_7] df[value].ewm(span7, adjustFalse, min_periods3).mean()adjustFalse这个参数务必加上。默认True时早期结果会做归一化修正数值形态和False有明显差别而且在数据头部差异最大。做特征时统一用False因为它的递推形式更接近在线更新的语义也便于你在流式计算里复现同样的值。如果训练时用了默认值、推理时自己手写递推公式两边必然对不上这类线上线下不一致的问题排查起来极其折磨。4.3 shift / diff / pct_change滞后特征的正确姿势滞后特征是时间序列建模的主力三个函数各管一段shift平移、diff差分、pct_change变化率。用法简单但有两个细节必须讲清楚。第一个是shift的方向。shift(1)是把数据往下推一行也就是当前行拿到的是上一行的值——这才是用历史预测现在的正确构造。shift(-1)是把未来值拉到当前行这是标签构造用的当前特征对应下期目标绝对不能出现在特征里。我在代码审查时见过把shift(-1)当特征写进流水线的那基本等于作弊。第二个是pct_change的除零和负值。当上期值为 0 时变化率是inf当上期值为负时变化率的符号会翻过来语义完全变味。稳妥做法是先检查分母分布必要时改用diff或者加一个极小偏移df[diff_1] df[value].diff(1) df[lag_1] df[value].shift(1) safe_base df[value].shift(1).where(lambda s: s.abs() 1e-9) df[pct_1] (df[value] - safe_base) / safe_base用where把接近 0 的分母替换成NaN比直接replace(0, np.nan)更安全因为后者只处理精确的 0处理不了1e-12这种实际上等效于 0 的值。除出来的inf一旦进了模型标准化会被直接带偏整列特征失效。4.4 一套可直接套用的特征构造流水线把上面的零件组装成一个函数这是我在项目里反复用的骨架。核心原则只有一条所有用到历史统计量的特征都要先在原始序列上shift(1)再计算否则当前点的值会进到自己的特征里。def build_ts_features(df, targetvalue, lags(1, 2, 3), windows(7, 14)): out df.copy() base out[target] # 滞后特征 for k in lags: out[flag_{k}] base.shift(k) # 滚动统计先 shift 再 roll防泄漏 for w in windows: shifted base.shift(1) out[froll_mean_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).mean() out[froll_std_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).std() out[froll_max_{w}] shifted.rolling(w, min_periodsmax(2, w // 2)).max() # 差分与变化率 out[diff_1] base.diff(1) safe base.shift(1).where(lambda s: s.abs() 1e-9) out[pct_1] (base - safe) / safe # 时间特征 idx out.index out[hour] idx.hour out[weekday] idx.weekday out[is_weekend] (idx.weekday 5).astype(int) # 周期性编码 out[hour_sin] np.sin(2 * np.pi * idx.hour / 24) out[hour_cos] np.cos(2 * np.pi * idx.hour / 24) return outshift(1)再rolling这个组合是整段代码的灵魂。很多人写特征时只对lag做了shift滚动统计直接在原序列上算结果第 t 行的roll_mean_7里包含了第 t 行的真实值——模型只要盯着这个特征就能看到答案离线评估自然漂亮。这个错误的隐蔽性在于单看代码base.rolling(7).mean()完全合理根本不像有 bug。唯一的防法是养成条件反射——看到滚动统计先问一句这个窗口的右端点包不包含当前时刻。时间特征的周期性编码也值得说一下。hour直接当数值喂进去模型会认为 23 点和 0 点相差 23实际它们只差 1。用sin/cos做环形编码把 24 小时映射到单位圆上距离关系就正确了。这个技巧对星期、月份同样适用代价是多了几列特征收益是模型不用再自己学这个映射关系小样本下提升挺明显。4.5 切分与防泄漏最后一道关卡特征表造好之后切训练集和测试集这一步必须按时间切不能随机切。随机切会让测试集的时间点散落在训练集内部模型实际是在插值而不是外推评估结果虚高。正确做法是按时间点截断split_point int(len(feature_df) * 0.8) train feature_df.iloc[:split_point] test feature_df.iloc[split_point:]如果要做多折验证别用默认的 K 折用时间序列专用的滚动切分保证每折的验证集都在训练集之后。还有一个细节标准化、归一化这类操作统计量只能用训练集算然后应用到测试集。很多人在整张表上fit完再切均值方差里已经包含了测试集信息这是典型的隐性泄漏。我习惯在流水线里把fit严格限定在训练切片上宁可多写两行也不图省事。提示造完特征先用一句feature_df.isna().mean().sort_values(ascendingFalse).head()看一眼各列缺失率。头部那几行被shift挖空是正常的如果有中间段落的列缺失率异常高多半是原始时间轴没补齐或者聚合口径写错了别急着往下走。5. 常见问题与排查实录5.1 问题速查表下面这张表是我这几年遇到频率最高的一批问题按症状—原因—处理三列整理遇到类似现象可以直接对号入座。症状大概率原因处理方式resample 报TypeError索引不是 DatetimeIndex检查set_index后有没有to_datetime聚合结果比原始数据少很多时间列解析失败变NaT后被丢用errorsraise重跑定位坏值日汇总和手工核对差一截closed/label口径不一致显式指定两个参数并与上游对齐rolling 头部全NaNmin_periods默认等于窗口长度降到窗口长度一半头部截断rolling 中间出现大段NaN时间轴有空洞时间窗口跨不过去先reindex补齐再滚动pct_change出inf上期为 0分母where掉近零值模型离线指标好得反常特征含未来信息排查shift(-1)、centerTrue、未 shift 的 rolling推理结果与训练分布不符ewm的adjust不一致统一adjustFalse合并后时间线整体偏移多源时间格式不统一分源解析后再合并统一时区5.2 几个只有踩过才知道的细节第一个是resample对空桶的处理。如果某个时间窗口内一个样本都没有聚合结果里这一行会存在但全是NaNcount会是 0。这在做特征时很危险——如果你用count当样本量特征模型会看到一堆 0如果用mean当特征那是一堆NaN。我的做法是单独保留count列作为质量指标其他统计量遇到NaN直接保留不填让下游根据count决定这条样本用不用。一刀切fillna(0)是最糟的选择会把没有数据伪装成数值为零。第二个是Grouper和groupby的配合。当你要按周聚合、但周的定义不是自然周比如从周一开始算resample(W)可能不符合预期pd.Grouper(freqW-MON, closedleft, labelleft)更灵活。另外多列分组时Grouper可以只对时间列生效其他列正常分组weekly df.groupby([ pd.Grouper(freqW-MON, closedleft, labelleft), device_id, ]).agg(val_mean(value, mean))这个写法比先resample再unstack干净得多尤其是在分组维度多的时候。第三个是性能。resample().agg()在千万行级别会明显变慢我实测过几种写法用命名聚合比传字典快用内置聚合函数比lambda快很多lambda里的s.isna().sum()这类操作会退化成逐组 Python 调用。如果数据量大把自定义逻辑挪到聚合之后用向量化算能省掉一大半时间。另外一个思路是先降采样到粗粒度再算比如日级特征不需要从分钟级逐点算可以先聚合到小时再往上滚误差在可接受范围内而速度提升明显。第四个是时间序列的对齐问题多序列合并时尤其明显。两张表的采样时刻不完全一致df1 df2会产生大量NaN因为 Pandas 按索引精确对齐。这时候用resample先统一到同一栅格或者用merge_asof做近似匹配。merge_asof要求两边都按时间排序direction参数控制取最近的过去还是最近的未来做传感器数据与事件打标对齐时特别好用比reindexffill更精确因为它可以选择最近邻而不是单纯的前向。最后分享一个我自己养成的习惯任何一次时间序列处理开头先打印三行——索引类型、索引是否单调递增、时间跨度。这三行几乎能提前暴露八成的潜在问题。print(type(df.index).__name__) print(df.index.is_monotonic_increasing) print(df.index.min(), -, df.index.max(), | rows:, len(df))看起来是废话但我确实靠这三行抓到过好几次数据只导入了半天的低级错误——文件截断、查询条件漏了时间上限这类问题不会报错只会让你在几小时后对着一堆反常的聚合结果发懵。花三秒钟打印比花三小时排查划算得多。