☰
Pandas时间序列处理实战:从类型转换到重采样与滚动窗口特征工程
2026/10/7 16:54:16 网站建设 项目流程

1. 先把数据喂给Pandas:类型转换与索引才是地基

很多人一上来就直接调resample()或rolling(),结果报错一堆,或者画出来的图横轴乱七八糟。这里百分之八十的问题都出在同一个根子上:你的时间列根本不是Pandas认识的时间类型。Pandas里处理时间序列,第一步永远是把纯文本或数字形式的时间列转成datetime64类型,然后把它设为索引。这两步不做扎实,后面全都是空中楼阁。

先看最常见的读取方式。假设你手里有一份CSV,日期列长这样:2024-05-01、2024/05/01、20240501,甚至还有带时分秒的。我用pd.read_csv()读完先不急着操作,第一件事是检查dtypes:

import pandas as pd df = pd.read_csv("sales_data.csv") print(df.dtypes)

如果看到object或者int64,那就说明时间列还没被识别。这时用pd.to_datetime()做转换,它是整个时间序列处理里最核心的入口函数:

df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d")

这里我强烈建议不要省略format参数。pd.to_datetime()虽然自带推断能力,但遇到2024/05/01这种斜杠格式或者01-05-2024这种歧义格式时,自动推断有可能猜错,尤其是在月/日和日/月之间摇摆的场景。主动传format相当于告诉解析器"别猜了,按这个规则来",速度更快,也更稳。如果数据量上了几十万行,这个参数带来的性能差异非常明显。

类型转完之后,把时间列设为索引:

df = df.set_index("date").sort_index()

sort_index()同样容易被忽略。时间序列分析有个默认前提:索引是升序的。你的原始数据如果按门店、按产品分组排列过,时间索引必然是乱的,不排序会导致后面的重采样、窗口计算出现莫名其妙的错位,而且定位问题时很难察觉。

还有一种常见情况是从Excel或数据库里读出来的时间列,里面混着NaT或空字符串。pd.to_datetime()遇到无法解析的值会直接抛错,这时候加上errors="coerce"可以把非法值统一转成NaT,后面再统一处理缺失。但注意,这个参数会让你"吃"掉所有异常值,最好先打印一下转换前后的行数差,确认究竟丢了多少数据。

有个小技巧我觉得很实用:如果你只想提取年、月、日、星期这些字段,没必要生成一列新数据,直接用.dt访问器就能取:

df["year"] = df.index.year df["month"] = df.index.month df["weekday"] = df.index.weekday # 0代表周一

要是还想判断某天是不是月末,Pandas的.is_month_end可以直接返回布尔值,做财务月度结算对账时特别省事。

所以,真正的时间序列处理,九成的工作量在数据进pandas之前就已经开始了。把"数据清洗"和"时间序列工具"分开看,是很多新手走弯路的重要原因。我习惯的做法是:拿到数据先建一个单独的清洗脚本,把类型转换、索引设置、缺失值处理全部做完,输出一个干净的中间文件,再进入分析环节。这样一来,后面做任何重采样、滚动窗口、特征工程,面对的都是已经标准化了的数据,心理负担小很多。

2. 重采样:把零散记录按时间格子重新码放

重采样是时间序列里最有实用价值、也最容易被误用的一类操作。它的本质很简单:把不规则的时间点,映射到固定的时间格子里,然后对每个格子的数据做聚合。你可以把它理解成整理货架——散落一地的商品(原始数据点),按保质期月份(时间格子)重新归位,然后数一数每个格子有多少件(聚合函数)。

Pandas的重采样函数是resample(),它需要配合一个"频率字符串"来指定时间格子的大小。常用的频率有这些:

频率代码含义典型场景
D自然日按天统计销量、流量
W自然周(默认周日结束)周报统计
M自然月(月末)月度财务结算
Q自然季度季度复盘
H/30min小时/半小时监控大盘、日志聚合
W-MON从周一开始的周业务周(周一为起点)
MS月初每月第一天对比

比如说,你有一份每笔订单的明细数据,想看每天的总销售额和订单数,做法是:

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

这段代码做的事情非常直白:把每笔订单按日期归类,然后对amount求和、对order_id计数。agg()里可以传字典,对不同列做不同的聚合操作,这比先求和再合并要简洁得多。更妙的是,你还能在同一个调用里同时算均值、最大值、最小值:

daily = df.resample("D").agg({ "amount": ["sum", "mean", "max", "min"] })

这样返回的是一个带多层列索引的DataFrame,列名变成("amount", "sum")这种形式。看的时候可能需要df.columns调整一下,或者用pd.NA占位。实际项目里我更喜欢先resample再分别计算,逻辑更清晰,排查问题也方便。

重采样有个关键的坑:结果索引的宽度由原始数据的时间范围决定,而不是由你期望的格子数量决定。如果原始数据只有一周,你resample("M")也就只能得到一个月的格子;如果原始数据横跨三年,即使中间有连续几个月的空窗期,Pandas也会忠实地为每个月份生成一行NaN。这个特性在做节假日较多的行业数据时很烦人,但反过来也是优点——它能让你一眼看出数据缺失的情况。

说到缺失,resample()之后经常会遇到某些格子没有任何原始数据的情况,默认生成NaN。这时可以用.ffill()或者.bfill()填空,也可以用.fillna(0)直接补零,到底用哪种取决于业务含义:缺数据等于没有还是等于未知。我的经验是,如果做的是销量类指标,空缺时间点通常可以视为0;如果是传感器读数这类连续型数据,用插值或者前向填充更合理。

另一个容易踩坑的点是时区。resample()默认按索引的本地时间切格子。如果你的数据带时区偏移(比如UTC+8),并且你想按UTC日切格子,最好先转换时区再重采样,不要依赖默认行为,否则节假日、换日点和夏令时的边界会让你的统计口径混乱。Pandas提供了.tz_convert()方法处理这类问题,后面第4节我会详细展开。

还有一个进阶用法值得掌握:resample()可以搭配apply()做任意自定义聚合。比如你想算每天的价格波动区间,可以先按天分组,再对最高价减最低价:

price_range = df["price"].resample("D").apply(lambda x: x.max() - x.min())

这种做法在股票、期货数据分析里几乎是标配。但你得知道,apply()的性能比内置聚合函数慢一个数量级,如果数据量大,建议先用.groupby()按日期分组,再配合pd.NamedAgg做并行计算。

3. 滚动窗口与shift:滞后特征和移动平均的实用套路

如果你做过机器学习预测任务,一定对"特征工程"这个词不陌生。时间序列的特征工程,核心就是两件事:滑动窗口统计和滞后值构造。这两招几乎能覆盖金融时序预测、销量预估、异常检测等一大半场景。

先说rolling(),滚动窗口。它的思路是:固定一个窗口宽度(比如7天),从序列起点开始,每次往后移动一格,对窗口内的数据做聚合。rolling(7).mean()就是最常见的7日移动平均,用来平滑短期波动、观察趋势。

df["sales_7d_avg"] = df["sales"].rolling(window=7).mean()

这里的window=7表示用当前行以及前6行的数据求平均。如果你希望窗口是"过去7天"而不是"前7行数据",那就得注意采样的频率一致性。如果数据是按天采样的,window=7刚好是7天;如果数据按小时采样,想算7天均值就得写rolling(window=168)或者用rolling("7D")。

Pandas从0.19版本开始支持基于时间的滚动窗口,写rolling("7D")时,窗口宽度按时间戳计算,即使中间有缺失的行或者数据频率不均匀,窗口也能正确对齐。这是一个非常实用的特性,尤其是在处理不规则时间戳的数据时,能让代码更贴近业务语义。

再来看shift(),滞后值。它的作用和SQL里的LAG()函数一模一样:把某一列的值向上平移若干行,让"昨天的值"出现在"今天的行"里,这样模型就能用昨天的数据预测今天的结果。

df["sales_lag1"] = df["sales"].shift(1) df["sales_lag7"] = df["sales"].shift(7)

这里的shift(1)是把整列下移一行,第0行变成NaN;shift(7)则是下移7行。做预测模型时,滞后1期和滞后7期(周同期)是最常用的两个特征。

rolling()和shift()组合起来,能构建出一大类特征。比如我想算"过去7天里,有多少天销量超过了当天的销量",这种问题听起来复杂,其实一行代码就能搞定:

df["count_gt_today"] = (df["sales"].shift(1).rolling(7).apply( lambda x: (x > df["sales"].loc[x.index[-1] + 1]).sum() ))

这段代码稍微有点绕,我解释一下:shift(1)先把"昨天的序列"准备好,然后.rolling(7)取过去7天,再用apply()自定义逻辑——窗口内的每一天跟"当前日"比较。这里有个隐含的坑:rolling()的窗口是左闭右开的,x.index[-1]是窗口最后一天,对应的"今天"需要加1偏移。这种写法适合做数据探索,但不适合大规模生产环境,因为逐行计算太慢。

如果你对性能有要求,更推荐用pd.Series.rolling().corr()或cov()这类内置方法,它们底层用Cython优化,速度比apply()快一个数量级。

还有一种场景值得单独拿出来说:多个时间序列之间的滚动相关性。比如你有两个产品的销量序列,想观察它们在过去30天内的相关性变化,可以这样写:

roll_corr = df["product_a"].rolling(30).corr(df["product_b"])

这行代码可以说是股票配对交易、电商交叉销售分析里的高频操作。它返回的序列每个点都代表"过去30天里两个序列的相关系数",能直观反映两个变量关系的动态变化。

最后提醒一个新手特别容易犯的错误:做完rolling()或shift()之后,新生成的特征在序列头部会产生大量NaN(因为窗口还没填满)。直接把含NaN的特征喂给机器学习模型,很多算法会直接报错或者把NaN当成一个值处理。我习惯先df.dropna()丢掉头部这几行,或者用bfill()补上,具体取决于你的数据量和建模策略。如果数据量够大,丢掉最前面的7行根本无所谓;但如果样本本身就少,就得考虑互补策略了。

4. 时间序列的缺失值与时区:两个常常被忽略的坑

缺失值处理在任何数据分析里都是重头戏,但在时间序列里,它有一个特殊性:时间顺序本身就是信息。你不能简单地把NaN整行删掉,因为删除会破坏时间连续性;也不能盲目填0,因为0可能代表"无数据"而不是"数值为0"。不夸张地说,缺失值填得对不对,直接影响后续重采样、滑窗、预测的质量。

我先说最简单的情况:时序数据里的NaN是孤立存在的,比如某个小时缺了一条记录。处理方式无非三种——删除、填充、插值。

删除是最省事的方式,但代价是丢掉其他列的有效信息。填充则要分业务场景:如果是销量、流量这类累计型指标,前向填充(ffill())很常用——今天没数据,默认沿用昨天的值;如果是传感器读数这类连续型指标,线性插值(interpolate())更合理——两个已知点之间,数值大概率是渐变的,而不是突变的。

# 前向填充:用上一个有效值补缺口 df["sales"].fillna(method="ffill") # 线性插值:在缺失点之间拉一条直线 df["temperature"].interpolate(method="linear")

interpolate()的默认方法是线性插值,对大多数场景够用了。如果你处理的是带周期性的数据(比如温度、电量消耗),method="time"会让插值基于时间间隔进行计算,比默认的等间距插值更符合实际。举个例子,假设两个观测点之间实际间隔了12小时,而数据里只隔了1行,默认插值会把中点当作"半行"的位置,而method="time"会老老实实按12小时的中点来算,结果自然更合理。

如果说缺失值处理是"明坑",时区问题就是"暗坑"。很多数据分析师在项目初期根本意识不到时区的存在,直到某一天发现:明明按小时聚合的数据,跨天的时候总是少一个小时或者多一个小时。这就是时区偏移在捣鬼。

Pandas处理时区有两种典型方式。第一种是数据本身就是UTC时间,你想展示成本地时间:

df.index = df.index.tz_localize("UTC").tz_convert("Asia/Shanghai")

tz_localize()做的动作是"给一个没有时区信息的时间戳加上时区标记",tz_convert()则是"把已标记的时区转换成另一个时区"。两者必须搭配使用,顺序不能反。如果你的索引已经带了时区信息,再做tz_localize()会直接报错。

第二种场景是数据本身带偏移(比如2024-05-01 08:00:00+08:00),你想统一成某个基准时间:

df.index = df.index.tz_convert("UTC")

这里要注意,tz_convert()不会改变时间戳的绝对时刻,只是换了个"钟表显示"而已。2024-05-01 08:00:00+08:00转换成UTC后变成2024-05-01 00:00:00+00:00,两者代表的是同一个瞬间。

在实际业务里,我见过最典型的时区事故是"跨天统计"。比如一个电商平台,订单时间记录的是UTC+8,但数据库里存的是UTC。如果直接用UTC做resample("D"),每天的边界就是UTC的零点,也就是北京时间的早上8点。这时候"某天的订单量"这个口径就完全错了。正确的做法是先转换时区再做频率聚合:

df.index = df.index.tz_convert("Asia/Shanghai") daily = df.resample("D").sum()

另外一个时区相关的隐藏操作是dst(夏令时)处理。虽然我们这边不实行夏令时,但如果数据源是海外业务,夏令时切换的那一天,时间索引会出现"重复的一小时"或者"缺失的一小时"。Pandas在tz_localize()时遇到不存在的本地时间会抛错,你可以用参数ambiguous="NaT"把这些时间点置空,或者用nonexistent="shift_forward"把不存在的时刻自动顺延一小时。实际项目中,我建议先检查数据里有没有date_range异常:df.index.is_monotonic_increasing和df.index[df.index.duplicated()]配合排查,能快速定位问题区域。

还有一个常被忽略的点:时区信息会影响rolling()跨天窗口的计算。同样是rolling("3D"),UTC+8和UTC的数据,窗口起止点完全不同。所以我一般会在数据加载阶段就把时区统一好,后续所有操作都在统一时区内进行,避免"分析到一半发现跨天边界歪了"这种糟心事。

5. 完整实战:从CSV到特征工程的一站式流程

前面讲了这么多理论和方法,现在把它们串起来,走一个完整的实战流程。我用一个电商场景模拟:原始订单表有订单时间、产品ID、销售额,目标是构建一份按天、按产品聚合的销售特征表,方便后续做销量预测或异常检测。

5.1 数据加载与清洗

import pandas as pd df = pd.read_csv("orders.csv", parse_dates=["order_time"]) df["order_date"] = df["order_time"].dt.normalize() # 去掉时分秒,只留日期

这里用parse_dates参数让pandas在读取时就完成日期解析,省去手动to_datetime()的步骤。dt.normalize()把2024-05-01 14:23:11归一化成2024-05-01 00:00:00,方便后续按天聚合。

接着处理缺失和异常值:

df = df.dropna(subset=["order_time", "amount"]) df = df[df["amount"] > 0]

假设产品ID也有缺失,补齐的方式取决于ID的语义。如果是内部编码,缺失说明数据质量有问题,建议直接剔除;如果是外部分类,缺失的可以统一标为"未知"。

5.2 聚合出时间序列

这一步的目标是把订单明细变成"每天每个产品的销售额序列":

ts = df.groupby(["order_date", "product_id"])["amount"].sum().reset_index()

groupby返回的结果,索引还是普通的整数索引,所以得.reset_index()把它变回规整的DataFrame。如果想直接得到一个以时间为索引的序列,可以不用reset_index(),让Pandas保留多层索引,后续重采样会更方便:

ts = df.groupby(["order_date", "product_id"])["amount"].sum()

多层索引的重采样稍微有点绕,你得先unstack()再resample():

ts_pivot = ts.unstack() # 行=日期,列=产品ID ts_daily = ts_pivot.resample("D").sum()

unstack()把产品ID从行索引搬到列索引,此时每列就是一个独立的产品销售序列。resample("D")把索引统一成自然日,同一天多笔订单自动累加。这一步做完,就得到了一张标准的"宽表",每一行是一个日期,每一列是一个产品的销售额。

5.3 构建特征窗口

假设我们要做销量预测,目标是预测"明天每个产品的销售额"。那就需要为每个产品构造滞后特征和滚动统计量。因为已经在宽表结构里,可以按列循环处理:

for col in ts_daily.columns: ts_daily[f"{col}_lag7"] = ts_daily[col].shift(7) ts_daily[f"{col}_rolling7_mean"] = ts_daily[col].rolling(7).mean() ts_daily[f"{col}_rolling7_std"] = ts_daily[col].rolling(7).std()

这里的lag7用来捕捉"上周同期"的规律,rolling7_mean和rolling7_std用来捕捉最近一周的整体水平与波动程度。对于周维度有明显周期性的业务(周内高、周末低),这三个特征组合起来信息量相当可观。

如果产品非常多(成千上万),循环写法的性能会很难看。这时更高效的做法是利用Pandas的窗口函数在DataFrame上直接并行操作:

lag7 = ts_daily.shift(7) roll_mean = ts_daily.rolling(7).mean() roll_std = ts_daily.rolling(7).std() # 给列名加后缀 lag7.columns = [f"{c}_lag7" for c in lag7.columns] roll_mean.columns = [f"{c}_roll_mean" for c in roll_mean.columns] roll_std.columns = [f"{c}_roll_std" for c in roll_std.columns] feat = pd.concat([lag7, roll_mean, roll_std], axis=1)

pd.concat把三个特征表按列拼起来,一次性得到全产品的特征矩阵。这种方式不仅代码简洁,而且利用了pandas底层的向量化计算,速度和循环不是一个量级。

5.4 处理NaN并拆分数据集

特征建好之后,前7行因为窗口没填满,一定全是NaN。我习惯先用dropna()把头部删掉,再按时间顺序切分训练集和测试集:

feat = feat.dropna() train = feat[feat.index < "2024-04-01"] test = feat[feat.index >= "2024-04-01"]

时间序列切分和普通机器学习不同,绝对不能随机打乱。你要预测未来,就必须用过去的数据训练、未来的数据验证,一旦混入未来信息,所有评估指标都会虚高,模型上线就现原形。这是新手最容易犯的错误。

5.5 接入模型

到了这一步,数据已经是标准的"特征矩阵+目标列"结构了。用LightGBM、XGBoost或者线性回归都可以。我在实际项目里常看到一种粗糙做法——直接把原始序列扔给LSTM,却忽略了特征工程。其实对于销量、流量这类强周期业务,只要把滞后特征和滚动统计做扎实,树模型往往就够用了,深度模型未必有优势。

如果你坚持要用LSTM,Pandas这边能做的准备是:把数据转成numpy数组,按固定时间步长构造样本序列。这一步可以自己写循环切分,也可以用keras.preprocessing.timeseries_dataset_from_array。老实说,我试过几次之后觉得,除非原始序列特别长且复杂,否则shift+rolling构造的特征,比直接喂原始序列效果更稳定,调试成本也低得多。

6. 我在实际项目中的一些取舍经验

到这里,Pandas时间序列的主要操作都过了一遍。最后聊几句我的个人经验,可能比前面的代码更值得你留意。

第一,能用内置方法就别用apply()。Pandas的rolling、resample内置聚合函数大多是Cython编译的,速度优势非常明显。我见过有人用apply(lambda x: x.mean())去算移动平均,数据量一上10万行,跑一次要几分钟;换成.mean()瞬间出结果。写代码时多花10秒钟查一下有没有内置方法,能省下后面大量的等待时间。

第二,时间序列处理一定要先画图。数据分析里"分箱统计"再复杂,都不如一张折线图直观。拿到数据先df.plot()看一眼整体形态——有没有断崖、有没有缺失区间、有没有明显的异常尖峰,比跑十行统计代码更高效。Pandas底层用matplotlib渲染,df.plot()基本是零成本出图:

ts_daily.plot(figsize=(12, 6))

如果发现图形断裂或者剧烈抖动,大概率是数据清洗环节出了遗漏。

第三,时区和频率口径在一开始就要定下来。我踩过的坑是,项目做到一半发现数据源有UTC和本地时间混用,结果所有统计都要推翻重来。现在我的习惯是:读取数据的第一时间就统一成目标时区,然后给每列备注清楚业务口径——"这个值是本地时间还是UTC?按天统计边界是零点还是早上8点?"这些信息写进代码注释里,能避免不少返工。

第四,绝对不要在模型里泄露未来信息。做滞后特征时,shift(n)意味着n期之前的数值是已知的。如果你在特征构建过程中不小心用到了rolling(window=7).mean()而窗口宽度包含了当期,那这个特征本身就包含了"今天的值",用今天预测明天,训练时效果好得惊人,测试时立刻崩溃。有一个简单的自查方法:对每条样本,确认它所有特征的时间戳都严格早于目标时间戳。

说了这么多,其实Pandas的API用久了会形成肌肉记忆,但真正决定你时间序列分析质量的,永远是"对业务的理解 + 对数据的敬畏"。工具只是把思路变成现实的手段,两件套缺一不可。希望这篇内容能帮你在处理时间序列数据的时候少走几步弯路。

按照这个实战流程走一遍,从原始CSV到最后喂给模型的特征矩阵,整个过程大概是:读取数据 -> 类型转换 -> 索引设置 -> 重采样 -> 滚动窗口 -> 滞后特征 -> 缺失处理 -> 删除头部 -> 切分训练集。每一步都是有明确目的的,不是机械地堆代码。你可以在自己的数据集上按这个链路跑一遍,遇到问题再回到对应的章节排查,应该能顺畅不少。

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

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

立即咨询