1. 从零准备:选对Pandas版本,建好时间索引
1.1 版本与安装的坑,我帮你趟过了
拿到“用Pandas处理时间序列数据”这个需求,很多人第一反应是打开PyCharm直接写import pandas as pd,结果一运行就报ModuleNotFoundError。这问题太常见了,热搜里“pycharm怎么安装pandas包”的搜索量从没下来过。这里我只说最稳的两种方式:
第一种,在PyCharm底部Terminal里直接执行:
bash复制pip install pandas
如果项目用了虚拟环境,记得先确认Terminal里显示的路径带venv字样,否则装到了全局Python里,项目照样找不到。
第二种,用PyCharm的Settings → Project → Python Interpreter → 加号按钮搜索pandas安装,适合不想敲命令的新手。
关键问题是版本适配。很多用户用的是Python 3.10,这时候装新版本pandas完全没问题。但如果你还在用Python 3.8或更老的3.7,就不能盲目装最新版了。我用一张表说明:
| Python版本 | 推荐Pandas版本 | 备注 |
|---|---|---|
| 3.7 | pandas 1.3.x | 再老版本对3.7支持不友好 |
| 3.8 | pandas 1.5.x | 稳定,用的最多 |
| 3.9 | pandas 2.0.x | 兼容性较好 |
| 3.10 | pandas 2.0.x及以上 | 目前主流组合 |
| 3.11+ | pandas 2.1.x及以上 | 新老API差异需注意 |
注意:Pandas 2.0有一个比较大的变化,就是
copy-on-write机制的引入。早期版本里,df[column] = ...可能会触发链式赋值的警告,到了2.1之后这种操作变得更加严格。如果你是从老版本升上来的,处理好这个区别,基本不会有什么大坑。
如果你需要读取Excel文件,别忘了装openpyxl引擎,这是热搜词里“pip install pandas openpyxl”的原因所在。只装pandas不装openpyxl,pd.read_excel()会直接报错。
bash复制pip install pandas openpyxl
1.2 一步到位:把普通列变成时间索引
时间序列数据处理的第一步,永远是让Pandas知道“哪一列是时间”。我见过太多人拿着一个带日期列的DataFrame就开始算滚动均值,然后发现结果完全不对,原因就是Pandas根本没把日期当日期处理。
最简单稳妥的转换方式:
python复制import pandas as pd
df = pd.read_csv('sales.csv')
print(df.dtypes)
# 输出: date object → 这说明date还是字符串,不是时间类型
df['date'] = pd.to_datetime(df['date'])
df = df.set_index('date')
print(df.index)
# 输出: DatetimeIndex(['2024-01-01', '2024-01-02', ...], dtype='datetime64[ns]', name='date', freq=None)
这背后有两件事同时发生了:第一,pd.to_datetime()把字符串列转成了datetime64[ns]类型;第二,set_index()把这一列提升为DataFrame的行索引,于是这个DataFrame就成了真正意义上的时间序列数据。
pd.to_datetime()的解析能力很强,像2024/01/01、20240101、Jan 1, 2024这些格式基本都能自动识别。不过如果你的数据源格式特殊,比如“2024年1月”这种中文格式,建议手动指定格式参数,否则Pandas可能识别不出:
python复制df['date'] = pd.to_datetime(df['date'], format='%Y年%m月')
1.3 从Excel读时间序列,别踩多级表头的坑
热搜词里“pandas读取excel文件”和“pandas数据类型转换”经常一起出现,因为这俩在真实业务场景里就是配套的。读取Excel时间序列数据时,最常见的坑是:Excel单元格里看着是日期,pd.read_excel读进来却变成了一串数字,或者读成了字符串。
这是因为Excel内部存储日期时用的是序列号,读入时如果没有正确识别单元格格式,就会保留原始序列号。解决办法是让pandas用openpyxl解析时主动指定dtype:
python复制df = pd.read_excel('data.xlsx', sheet_name='Sheet1', parse_dates=['交易日期'])
关键参数是parse_dates,它告诉pandas把指定列按时间类型解析,而不是读进来自己再转换。这个参数也能同时转换多列:parse_dates=['日期1', '日期2']。有时候Excel里第一列是日期,但压根没列名,那就用index_col=0, parse_dates=True,让pandas把第一列既当索引又当时间列来处理。
如果Excel里有多级表头(常见于手工维护的报表),可以用header=[0, 1]指定读取前两行作为列索引,读完后时间列的名字就变成了元组。这种情况我自己也不推荐在pandas里硬处理,更建议先把Excel表头改成干净的单行版本。记住一句话:pandas处理时间序列的能力很强,但输入数据的“脏乱差”会让这个能力大打折扣。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据清洗:时间序列的第一步往往是“对齐”
2.1 先排序还是先去重?顺序错了会丢数据
时间序列数据最常见的数据质量问题来自两方面:乱序和重复。从数据库导出的记录经常不是按时间排序的,比如订单表按用户ID分区导出、传感器数据因采集延迟造成写入乱序等。这时候你直接plot()画出来的图就是一团乱麻。
我的习惯是先排序,后去重:
python复制df = df.sort_index() # 如果已经把日期设为索引
df = df.reset_index().drop_duplicates(subset=['date'], keep='first').set_index('date')
为什么先排序?因为drop_duplicates默认保留的是第一次出现的行。如果没排序就先去重,保留的可能是时间乱序状态下碰巧排在最前面的那条,而不是真正最早的那条。这在后续做时间序列对齐时会引发隐性问题。
热搜词里有一个很形象的场景:“pandas如果指定两列的值均相同,则取第一条数据即可”。用代码表示就是:
python复制df = df.drop_duplicates(subset=['store_id', 'date'], keep='first')
这在做多店铺、多商品的时间序列合并时特别常见。同一个店铺同一天可能有多个订单记录,聚合分析时只需要取一条汇总值,用这个参数就够。
注意:
keep='first'是默认值,但务必明确写出来,因为团队协作时别人读代码能看懂你的意图,不会误以为你忘了处理重复值。
2.2 缺失时间戳的补齐方法
时间序列数据缺失有两种:一种是指标值缺失(比如某天销售额是NaN),另一种是整行时间戳缺失(比如这天根本没记录)。前者好办,df['sales'].fillna(0)或interpolate()就能处理。后者麻烦些——如果某天压根没行,Pandas不会自动把空的一天补上。
这时候就需要reindex或者更直白的asfreq方法:
python复制# 生成一个完整的时间范围
full_index = pd.date_range(start='2024-01-01', end='2024-12-31', freq='D')
df = df.reindex(full_index)
reindex的核心思路是:以完整时间索引为标准,把缺失的行用NaN自动补上。补完之后可以再做填充:
python复制df['sales'] = df['sales'].ffill() # 用前一个有效值填充
这个方法在处理传感器数据时很好用,比如每5分钟一条记录,中间有设备断连产生5小时的空窗,用ffill填充后可以保持数据连续,后续做窗口计算时不会因为索引断裂导致rolling断开。
2.3 类型转换:字符串与时间类型来回切换
热搜词里“pandas 数据类型转换”出现频率很高,说明这是很多人绕不过去的坎。时间序列场景里,类型转换主要体现在三个方向:
- 字符串转时间:
pd.to_datetime(),最常用 - 时间转字符串:
df.index.strftime('%Y-%m-%d'),导出或拼接时用 - 时间戳转时间类型:
pd.to_datetime(df['timestamp'], unit='s'),处理Unix时间戳时用
第三种特别值得注意。很多IoT设备和第三方API返回的是毫秒级或秒级时间戳,比如1700000000,直接读进来pandas只会当它是整数,画横轴时就会出现一个巨大的数字。正确做法是指定单位:
python复制df['时间'] = pd.to_datetime(df['时间戳'], unit='s') # 秒级
df['时间'] = pd.to_datetime(df['时间戳'], unit='ms') # 毫秒级
同时,pd.to_datetime还有一个errors='coerce'参数,遇到没法解析的非法日期时会转成NaN而不是抛异常中断任务。数据量大的时候这个参数很救命,因为单独排查哪一行格式错了会耗费大量时间。我的习惯是先coerce再检查NaN的数量,快速定位脏数据占比。
3. 时间序列的灵魂操作:resample、rolling与shift
3.1 resample重采样:从日数据到周月数据的正确姿势
时间序列分析中,最常用的聚合操作就是重采样。日粒度数据太多、太碎,看趋势还是要看周、月、季度。resample就是干这个的:
python复制# 按周聚合,周一作为周起始
df.resample('W').mean()
# 按月聚合
df.resample('M').sum()
# 按季度聚合
df.resample('Q').max()
重采样代码非常简单,但背后的商业含义需要想清楚:周汇总用均值还是求和?这就取决于你的指标类型。流量、销售额这类可加性指标用sum,温度、响应时间这类强度指标用mean,设备峰值类指标用max或min。选错聚合方式会让整个分析失真。
还有一个小细节:重采样后的索引会变成周期的开始日期。比如周数据,索引显示周一那天;月数据显示月初。如果希望显示周期结束日期,可以加label='right'参数:
python复制df.resample('W', label='right').mean()
这在金融数据处理里特别常用,比如周收益率要对应到周五那天的日期,而不是周一。
3.2 rolling滚动窗口:移动平均的真实用途
滚动窗口大概是时间序列操作里我使用频率最高的方法。均线策略、异常检测都离不开它。看最基础的一段代码:
python复制# 7日移动平均
df['sales_ma7'] = df['sales'].rolling(window=7).mean()
# 30日移动平均
df['sales_ma30'] = df['sales'].rolling(window=30).mean()
rolling的本质是:对每个时间点,向前取包含自己在内的N个观测值做统计计算。窗口内的统计量可以是均值、标准差、最大值、最小值、中位数等。我常用的几个:
| 统计量 | 方法 | 典型应用 |
|---|---|---|
| 移动平均 | rolling(7).mean() |
平滑短期波动,观察趋势 |
| 滚动标准差 | rolling(7).std() |
度量短期波动性 |
| 滚动最大值 | rolling(7).max() |
计算近期峰值 |
| 滚动分位数 | rolling(20).quantile(0.9) |
设置异常阈值 |
这里有个容易踩的坑:默认情况下,rolling(7)在数据不足7个点时返回NaN。很多人在数据头几天发现结果是空,就慌了,其实这是正常行为。如果你希望足够数据前先用已有数据计算(比如前3天用3个点的均值),可以把min_periods参数调小:
python复制df['sales_ma7'] = df['sales'].rolling(window=7, min_periods=1).mean()
这背后的取舍是:前几天的估计值不够稳定,但不会出现缺口。我的建议是,如果数据本身量级够大、时间跨度长,保持默认的min_periods=None更严谨,让前6天自然缺失,不要用不稳定的估计污染后续分析。
3.3 shift与diff:滞后特征和差分操作
如果要给机器学习模型准备时间序列特征,shift是你最需要熟练掌握的方法之一。它做的事情很简单:把整列数据“往下挪”若干行:
python复制df['sales_lag1'] = df['sales'].shift(1) # 昨天的销售额
df['sales_lag7'] = df['sales'].shift(7) # 一周前的销售额
为什么要这样?因为很多预测模型(尤其是回归类模型)并不知道时间顺序,它看到的每一行都是独立的样本。要让模型知道“今天和昨天有关系”,就必须手动把昨天的值作为今天的特征放进去。shift(1)就是构造这种滞后特征的关键手段。
diff则是计算一阶差分:
python复制df['sales_diff'] = df['sales'].diff(1) # 今天相比昨天的变化量
差分操作可以把非平稳序列转成平稳序列。很多股票价格序列是非平稳的,直接建模效果很差,但收益率序列(即一阶差分)就平稳很多。这在ARIMA等统计模型中是强制要求的前置步骤。
注意shift之后,滞后特征的前几行一定是NaN(因为那些位置没有更早的数据),建模前需要配合dropna()一起用,否则模型fit时会报错。我习惯这样一套组合拳:
python复制df['特征1'] = df['sales'].shift(1)
df['特征2'] = df['sales'].shift(7)
df['目标'] = df['sales'] # 预测当天销售额
df = df.dropna().reset_index(drop=True)
4. 特征工程与模型衔接:从Pandas到预测模型
4.1 时间特征抽取:让模型理解“时间”的规律
基于Pandas做时间序列预测,最常配合的是LSTM、GRU这类深度学习模型,也有很多人用XGBoost、LightGBM做回归式预测。热搜词里“lstm时间序列预测python”和“gru实现时间序列预测”都有很高的搜索量,说明这个方向是真刚需。
但很多人一头扎进模型调参,却忽略了时间特征的工程化。Pandas里提取时间特征非常顺手:
python复制df['year'] = df.index.year
df['month'] = df.index.month
df['day'] = df.index.day
df['dayofweek'] = df.index.dayofweek # 0=周一
df['weekofyear'] = df.index.isocalendar().week
df['hour'] = df.index.hour
这些特征对模型有实际帮助。比如预测某门店销售额,周末和工作日差异巨大,dayofweek就是强特征;预测供暖设备能耗,month和hour直接相关。
更进阶一些,还可以做周期性编码。月份和小时是循环变量,12月和1月相邻,但数字上21和22差得多远?如果直接给模型喂数字,模型会认为“12月之后是13月”,逻辑上就错了。解决办法是sin/cos编码:
python复制df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
这步需要import numpy,两个库搭配使用在时间序列场景里几乎是标配。热搜里“numpy和pandas库的使用”总是并列出现,原因就在这里。
4.2 构造监督学习所需的特征矩阵
LSTM和GRU这类网络需要的是三维输入(样本数, 时间步长, 特征数),而不是Pandas的二维表。这里就要用到滑动窗口法来构造样本。
假设我们要用过去7天的销售额来预测今天的销售额:
python复制import numpy as np
def create_sequences(data, window_size=7):
X, y = [], []
for i in range(len(data) - window_size):
X.append(data[i:i+window_size])
y.append(data[i+window_size])
return np.array(X), np.array(y)
# data为单列销售额的numpy数组
X, y = create_sequences(df['sales'].values, window_size=7)
# X形状: (行数, 7, 1)
这里是Pandas和深度学习模型衔接的关键一步。Pandas负责前期的清洗、对齐、填充、特征提取,等到数据准备成干净的、有规律的序列后,再用values转成numpy数组喂给模型。这个“Pandas做前端,numpy做中转,模型做后端”的分工,是我个人最推荐的工作流。
如果你用的是XGBoost这类表格模型,就不需要构造三维数据,只要用shift构造好滞后特征,保持二维表结构即可:
python复制features = ['sales_lag1', 'sales_lag2', 'sales_lag7', 'dayofweek', 'month']
X = df[features]
y = df['sales']
4.3 训练集验证集切分:别把未来的数据泄进来
这是时间序列预测中最容易被忽视、也最容易犯的错误。处理普通机器学习任务时,大家习惯用train_test_split随机切分,这在时间序列里是灾难性的——模型在训练阶段看到了“未来的数据”,验证集效果虚高,上线后就崩。
正确做法是按时序顺序切分:
python复制train_size = int(len(df) * 0.8)
train_x = X.iloc[:train_size]
train_y = y.iloc[:train_size]
val_x = X.iloc[train_size:]
val_y = y.iloc[train_size:]
一个进阶建议是使用时间序列交叉验证,比如sklearn的TimeSeriesSplit。它的逻辑是:第1折用第1段训练,预测第2段;第2折用第1、2段训练,预测第3段;以此类推。这能更充分利用历史数据,同时保证每一步验证都不会用到未来信息。在模型调参阶段,用这种方式评估结果比单次切分稳定得多。
5. 我在实际项目中踩过的几个坑
5.1 时区和UTC偏移问题
处理跨时区的时间序列数据时,如果你直接pd.to_datetime()然后又开始聚合计算,很可能会遇到两种坑:一是采用了本地时区,但数据源实际是UTC;二是一些冬令时夏令时切换的国家,一天可能只有23小时或25小时。
解决办法是解析后用tz_localize和tz_convert处理:
python复制df.index = pd.to_datetime(df.index).tz_localize('UTC').tz_convert('Asia/Shanghai')
重要的是先统一成UTC再做时区转换,不要拿着已经混入本地时区的时间再转换,那样原始信息早就丢了。涉及跨国业务或日志类数据时,这个细节很关键。
5.2 重采样后的索引对齐陷阱
重采样后,索引变成了周期起点(或终点),如果你不检查就直接和其他DataFrame做join操作,很容易出现“看起来同一天,实际对不上”的问题。比如一个表是resample('W').mean()得到的周一到周日聚合结果,索引是周一;另一个表是业务系统导出的周度数据,日期标记为周五。两者在merge时仅靠日期对齐就会错位。
我的经验是:在任何merge操作之前,都先看一眼两个DataFrame的索引标签是否属于同一维度(日、周、月)以及是否用相同的对齐锚点。可以先统一把周期标签翻成周期结束日:
python复制df_weekly = df.resample('W', label='right', closed='right').mean()
df_weekly.index = df_weekly.index - pd.Timedelta(days=6) # 转成周五
这步操作的具体取值要结合你的业务口径,重点在于你有意识地控制索引对齐,而不是依赖运气。
5.3 pandas版本更替造成的API变化
最后说一个只会在实际更新时炸出来的坑。Pandas更新到2.x之后,df.append()方法彻底移除了。早期版本的pandas教程里经常用df.append()来拼接数据,在2.0版本直接AttributeError。正确替代是pd.concat()。这背后是Pandas团队在推动代码库走向更清晰的方向。
python复制# 老版本写法(已废弃)
df_all = df1.append(df2)
# 新版本正确写法
df_all = pd.concat([df1, df2], axis=0)
类似的还有一些变化:.iteritems()改成了.items(),inplace参数在逐步弃用。我建议所有用Pandas做时间序列的同学,升版本前先看一眼官方文档的版本迁移说明,特别是项目里如果用到3.10以下的Python,升级Pandas大版本时一定要先在测试代码里跑通核心逻辑。
我不止一次处理过因为生产环境自动升级Pandas导致线上脚本挂掉的事故。如果你在团队里维护时间序列相关任务,建议在requirements.txt里锁死Pandas版本号,比如pandas==2.1.4,不要用pandas>=2.0这种粗粒度约束。稳定优先,永远没错。
最后分享一个小习惯:每处理完一个时间序列数据集,我会把索引的时间频率、起止时间、数据量以及缺失值占比记录成一段元数据,放在脚本开头注释里。这听起来繁琐,但一个月后回来看脚本,省下的时间远超当初记录花掉的时间。时间序列分析这条路上,可复现性比技巧本身更重要。
