资讯动态

Pandas时间序列分析实战:从时间索引到重采样与滚动窗口

发布时间:2026/10/18 9:55:44 来源:尧图企业网站定制
这是一份销售数据几十万行里面只有下单时间和订单金额两个字段靠谱。我的任务很明确把2024年1月到5月的订单按周汇总出趋势图。当时脑子里第一个念头是直接groupby结果写完df.groupby(下单时间).sum()一看输出的一堆行连个时间粒度都没有——我用的是字符串索引Pandas 根本不知道这是一列时间只把它当成普通文本在分组。这就是这篇文章想聊的起点。用 Pandas 处理时间序列数据真正的门槛不在 API 本身而在于你有没有先把数据变成Pandas 能理解的时间序列。很多教程一上来就教resample、rolling但如果你连索引都还是字符串后面所有高级操作都会卡壳。我会从最基础的时间索引入手把重采样、窗口计算、时区对齐、性能优化这几个高频场景逐个拆开每一步都给出可以直接复制运行的代码并在关键节点讲清楚为什么必须这么做。无论你是刚接触数据分析的小白还是已经在用 Pandas 处理日频数据的分析师这篇文章都值得从头到尾过一遍——里面有不少坑是我实打实踩过之后才总结出来的。1. 时间索引整个时间序列分析的基石很多人拿到时间数据的第一反应是这还不简单pd.to_datetime转一下不就行了。对转一下确实简单但转完之后呢如果你只是把转换结果放回原来的列而没有把它设为索引那 Pandas 后续的各种频率操作依然拿你没办法。我先把这个观念捋顺。1.1 为什么第一步永远是设置索引而不是直接分组groupby能做很多事情但它在处理连续时间区间上的聚合时天然缺少两个能力第一groupby只认相等的标签。如果你想按周聚合用groupby必须先把每个时间点归到它所属的那个周一或周日这一步的归组逻辑得你自己写而resample这类专用的时间频率操作方法内部已经实现了以固定频率切分时间轴的完整逻辑。第二groupby不会帮你处理时间缺失的问题。一整周七天如果其中一天没有任何数据groupby的结果里这一天直接消失而对于时间序列我们往往需要保留这个空档让它显示为 NaN 或者 0。这背后是稀疏数据与完整时间轴两种思路的根本差异。所以我的习惯是拿到任何带时间字段的数据第一件事就是判断它会不会被当做时间序列来分析。只要答案是会立刻执行两步import pandas as pd df pd.read_csv(orders.csv, parse_dates[下单时间]) df df.set_index(下单时间).sort_index()parse_dates让 Pandas 在读取 CSV 的时候就把该列解析成datetime64类型比事后pd.to_datetime批量转换更高效。set_index把时间列提升为索引sort_index则保证索引严格递增。这三个操作连在一起是时间序列分析的标准起手式。1.2 to_datetime 解析格式的坑与 errors 参数的用法如果你手里的数据不是从 CSV 直接读的而是一堆杂乱无章的字符串比如从接口、Excel、日志里扒出来的那就要用pd.to_datetime手动转了。这个函数大多数时候很聪明但有两个情况它会翻车一是时间格式不标准。比如2024/1/5 8:30这种中文环境下常见的斜杠格式to_datetime通常也能识别但2024-01-05T08:30:00Z这种带T和Z的 ISO 格式如果不声明utcTrue解析出来的时间会被当成没有时区信息的 naive 时间后续一旦涉及时区转换就可能错得离谱。二是混合了非法值的列。比如订单表里混进了一行未知时间to_datetime默认会直接抛异常把整个流程中断。这时候要用errorscoercedf[下单时间] pd.to_datetime(df[下单时间], errorscoerce) df df.dropna(subset[下单时间])coerce会把无法解析的值变成NaTNot a Time然后我们再统一丢弃。这一招在实际清洗数据时出镜率极高我几乎每次处理脏时间数据都会用。1.3 索引排序与重复时间戳最容易忽略的两个检查时间索引设好之后有两件事必须马上确认否则后面的resample、rolling会给出静默错误的答案第一索引是否严格递增。Pandas 的很多时间操作尤其是rolling和数据对齐都建立在索引有序这个假设上。如果索引乱序哪怕只有一行位置不对rolling的结果都可能向前或向后错位。所以我上面强制加了一步sort_index()多写一行代码换来的是后续所有计算的可靠性。第二是否有重复的时间戳。同一秒内出现了两笔订单这在真实业务里太常见了。重复索引本身不会让resample报错但会带来严重的聚合歧义你按订单求均值重复的时间戳到底算一笔还是两笔保险的做法是先用index.is_unique检查如果有重复再做一次归并处理if not df.index.is_unique: df df.groupby(level0).sum()把同一时刻的订单金额相加得到该时间点的总金额。这一步做完时间轴上每个点就唯一了。2. 重采样数据粒度的自由切换以及它的好几个陷阱时间索引就位之后第一个高频操作就是重采样。简单说resample就是改变时间频率的聚合操作把分钟级数据压成小时级、把日频数据聚成周频或月频。在业务看板上我们几乎每天都离不开它——日报看小时粒度周报看天粒度管理层看月粒度底层数据明明一模一样换个频率就是另一张报表。2.1 resample 和 groupby 的本质区别数据的时间归属上面我讲过groupby分组时间依赖自定义这里把resample的核心逻辑拆开讲透。resample做的事情可以理解为三个步骤把连续的时间轴按指定频率切分比如从2024年1月1日到2024年1月31日按周切成对应的几个区间为每个切分出来的区间分配一个标签标签是时间点代表这个区间对落在同一区间的所有行做聚合。这里最容易被绕晕的是第二步的标签归属。举个例子按周W重采样时Pandas 默认的标签和区间边界是labelright、closedright也就是说一个周区间的定义是从某一周的周一到下周一之前而这个区间的标签是下周一。如果你业务上习惯用周一代表这一周就会看到周一标签的数据实际包含的是上周一之后到本周一之前的日期。为了更直观我画过一张对照表用代码模拟# 生成1月1日到1月14日的日频数据 idx pd.date_range(2024-01-01, periods14, freqD) s pd.Series(range(1, 15), indexidx) # 按周重采样分别观察两种标签设置 print(s.resample(W-MON, closedleft, labelleft).sum()) print(s.resample(W-MON, closedleft, labelright).sum())W-MON表示每周从周一开始算。用closedleft时区间是左闭右开——也就是说每周一零点算新一周开始到下周一之前结束。标签设在左边那一周的数据就标在本周一标签设在右边就标在下周一。我刚接触那会儿搞混了不下三次后来索性给自己定了一条规矩如果是周报一律用labelleft, closedleft让一周的数据标在周一的标签上和业务直觉完全一致。2.2 各种频率字符串的语义那些令人困惑的 W、M、Q 和它们的变形resample的第二个大坑是频率字符串。D是自然日H是自然小时T是分钟M是日历月末MS是日历月初很多人知道这些。但周和季度就没那么直观了。W默认是以周日结束的一周而加了后缀的W-MON、W-SUN、W-FRI则明确指定了一周的结束日。每个国家和每个公司的财年规则都不同美国很流行周日结束国内很多业务习惯周五或周日晚。所以当你看到别人的代码里写着resample(W-FRI)一定要意识到它和resample(W)算出来的每一周边界完全不同。再比如M是日历月最后一天作为标签但 2 月的最后一天是29号或28号3 月是31号标签并不对齐到统一频率。如果你希望按每月固定日聚合比如每月15号结算Pandas 的M帮不了你需要用pd.Grouper(freqNone, ...)之类的自定义逻辑。这些细节在分析金融月度数据时特别重要否则你会发现两个月的月标签间隔居然不是固定天数。我整理了一张常用的频率对照表贴在这里方便查阅频率字符串含义标签示例T/min分钟2024-01-01 08:05:00H小时2024-01-01 09:00:00D日历日2024-01-05 00:00:00W-MON每周一结束的周周一为最后一天2024-01-08 00:00:00W-FRI周五结束的周2024-01-05 00:00:00MS日历月初2024-02-01 00:00:00M日历月末2024-02-29 00:00:00QS季度初2024-04-01 00:00:00Q季度末2024-03-31 00:00:00YS年初2024-01-01 00:00:00Y年末2024-12-31 00:00:002.3 实战案例小时级订单数据按周汇总的完整过程理论讲再多不如跑一遍。我直接用最典型的业务场景演示假设我们有一份小时级的订单量数据要汇总成周趋势同时计算每周的环比变化。import numpy as np # 模拟2024年1月1日到2024年2月29日的小时级订单量 idx pd.date_range(2024-01-01, 2024-03-01, freqh, inclusiveleft) np.random.seed(42) orders np.random.poisson(lam20, sizelen(idx)) hourly pd.Series(orders, indexidx, name订单量) # 按周聚合周五作为周的最后一天区间左闭右开 weekly hourly.resample(W-FRI, closedleft, labelleft).sum() print(weekly.head()) # 计算周环比变化率 weekly_growth weekly.pct_change().mul(100).round(2) weekly_df pd.DataFrame({周订单总量: weekly, 周环比(%): weekly_growth}) print(weekly_df)这段代码里有三个值得注意的细节一是inclusiveleft因为date_range在指定2024-03-01时如果不加这个参数会多出一个2024-03-01 00:00的边界点而我们的数据止于2月29日23:00。这种边界多出一点的问题在日频以上尤其容易犯。二是resample之后得到的对象聚合方式用.sum()但如果你需要求均值、求最大值、求最后一个观测值比如取每周末的库存数字分别对应.mean()、.max()、.last()。它们的语义差异很大用错了我见过不少。三是pct_change生成的第一行会是 NaN因为第一周没有上一周作对比。这在出报表时要注意处理一般我会fillna(0)或者干脆保留空值让前端展示的时候自己判断。3. 窗口计算与滚动统计移动平均背后的细节陷阱重采样解决的是跨时间段聚合的问题而滚动窗口解决的是滑动着看趋势的问题。移动平均Moving Average是滚动窗口最常见的一种应用用过去N天的平均值来平滑每日的波动让趋势更明显。3.1 rolling 窗口的两大定义固定窗口 vs 可变长度窗口Pandas 的rolling函数有几个参数用错了结果会完全不对。最核心的就是window的定义。window可以是整数也可以是一个时间频率字符串比如7D。两者最大的区别在于窗口长度是否固定。举个例子假设你有2023年1月、2月两个月的日数据。rolling(window7)表示最近7个观测值不管它们分布在多长时间跨度上。但如果你的数据在春节期间连续停了7天没更新那rolling(window7)实际上就滚滚了将近半个月的时间跨度。而rolling(window7D)的含义是最近7个自然日的数据哪怕只有1个观测值落在这个窗口里它也会被纳入计算。当你的数据是有缺失、有节假日的真实业务数据时选哪种窗口至关重要。金融工作日数据通常用整数窗口每个交易日等权重但零售行业按自然日分析时用7D才能准确对比上周一 vs 这周一。# 连续观测数据用固定窗口 s.rolling(7, min_periods3).mean() # 业务数据带缺失用时间窗口 hourly[7日均值] hourly[订单量].rolling(7D, min_periods1).mean()3.2 min_periods 和 center两个最容易被忽视的参数rolling还有两个参数我用了一年之后才彻底理解它们的价值min_periods窗口内最少需要多少个非缺失值才计算。前7天没有足够数据时默认会全部返回 NaN但通过设置min_periods3只需要3天有数据就开始计算均值。这在冷启动阶段比如新店开业的头几天特别有用——你不想让前6天全无数据但也不想让第1天的单个值就影响判断。centerTrue默认情况下窗口的锚点在最右边也就是说今天计算的是过去N天的均值结果天然滞后N/2天。而centerTrue会把窗口锚点移到正中间此时第 t 天的值代表以第 t 天为中心的前后各 N/2 天的均值。中心平滑在处理趋势图时更美观但它会引入未来数据在一些需要实时预测的场景中属于泄漏look-ahead bias。如果做量化回测绝对不能用centerTrue如果只是做经营分析图表无所谓。3.3 加权移动平均用 ewm 解决突变噪音问题平权滚动平均每个观测值权重相同有一个天然缺陷对最近变化反应迟钝。比如某一天订单量从20突增到100等这个突增进入窗口移动平均才会被拉高而且突增被平均后幅度也被摊薄了。现实中我们往往更关心最近发生了什么。指数加权移动平均ewm就是为此而生的它给每个观测值分配一个指数衰减的权重越靠近当前时刻权重越大。# alpha 控制衰减速度alpha 越大越看重近期数据 trend daily[订单量].ewm(alpha0.3, min_periods5).mean()alpha的含义需要解释一下。alpha0.3表示当前观测值占30%权重剩下的70%再由历史数据按比例分配。你可以想象成昨天的记忆只能留下七成——alpha越大记忆衰减越快曲线越灵敏但噪音也越多alpha越小曲线越平滑但对近期变化的反应越慢。实际调参时我一般在 0.1 到 0.5 之间试探找一条既平滑又能抓住关键拐点的曲线。3.4 shift 与 diff让时间序列开口说话的两个算子最后介绍两个与窗口无关但极其实用的操作。shift(k)把整条序列向前或向后平移 k 个位置。shift(1)意味着昨天的值。计算日环比时我们经常需要今天的值 - 昨天的值。daily[昨天订单量] daily[订单量].shift(1) daily[日环比增幅] daily[订单量] - daily[昨天订单量]diff(k)直接计算今天比 k 天前多了多少。diff(1)的等价写法就是series - series.shift(1)但diff的代码更简洁daily[日环比增量] daily[订单量].diff(1)diff和shift在计算同比去年同月对比、环比上月对比时是绝对的主力军。我经常用一个pd.DataFrame把它们组合起来做特征工程一次性生成滞后值、变化量、变化率三组特征喂给下游的模型。4. 时间序列对齐、时区与周期转换容易翻车的三个细节如果说上面几节是高频率使用的技术这一节就是我踩坑之后最想让人知道的低频率但高杀伤力的细节。这些细节平时不碰一碰就是大坑且坑与坑之间没有明显的报错提示只有等结果出来才恍惚发现哪里不对。4.1 时区到底是用户时区还是业务时区统一UTC才是王道很多做国内业务的同学一到手数据就是北京时间压根不会碰到时区问题。但一旦数据源变成跨国业务比如跨境电商平台的订单记录或者从数据库导出时默认存储的是 UTC 时间问题就来了。Pandas 处理时区有两种状态naive不携带时区信息和aware携带时区信息。两者不能直接比较否则会抛TypeError。针对跨时区数据我的处理原则是所有数据一律内部统一使用 UTC只在展示时转换到业务时区。具体操作如下# 假设原始时间是北京时间字符串 df[时间] pd.to_datetime(df[时间], utcTrue) # 规范化为UTC aware时间 # 需要展示或按本地时间聚合时再转换 local_time df[时间].dt.tz_convert(Asia/Shanghai) # 重采样前必须用本地时区索引或者直接用UTC索引 df.set_index(时间, inplaceTrue) daily_local df.resample(D).sum() # 按UTC的日聚合这里有一个隐蔽的坑resample(D)使用的是索引的时区。如果你的索引是 UTC那么今天是按照 UTC 零点来的而不是北京时间的零点。如果需要按北京时间的一天比如统计自然日的订单量必须先把索引转换到目标时区再重采样df.index df.index.tz_convert(Asia/Shanghai) daily_beijing df.resample(D).sum()另外要格外小心夏令时。如果你接触的是北美或欧洲业务数据3月和10月附近会出现一天23小时或一天25小时的情况。resample(D)对这种天数会表现得很诡异。我的建议是底层一律 UTCUTC 没有夏令时需要展示时再转本地时区。这个问题我在做北美业务分析时碰到过那时每天北京时间8点看报表数据总是比前一天多1小时或少1小时排查了大半天才定位到是和夏令时切换撞上了。4.2 不同频率的数据对齐reindex 与 asfreq 的选择时间序列分析中手上经常有两份频率不同的数据一份是日频销售额一份是周频库存。要放到同一个模型里做分析必须先把它们对齐到同一频率。对齐高频数据到低频直接resample就行但把低频数据展开成高频就有两种路线asfreq()是纯频率转换它只是把标签变密不会填充任何值。比如把月频数据展开成日频每天的值都会是 NaN除非你自己用插值去填。reindex()则允许你指定一个新索引可以完全自定义对齐目标配合method参数还可以做前向/后向填充。实际业务中我极少把低频数据展开成高频再分析——因为这等于制造了不存在的信息。更常见的做法是把高频数据重采样到低频对齐后再做计算。# 日频数据聚合成周频 sales_weekly sales_daily.resample(W-FRI, closedleft, labelleft).sum() # 两份周频数据对齐取交集 merged pd.merge(sales_weekly, inventory_weekly, left_indexTrue, right_indexTrue, howinner)4.3 周期转换的典型场景从日频到月频环比最后再说一个高频出现的操作——从日频转月频并且计算月度环比。这里又想强调一次频率字符串的玄机daily pd.Series(some_data, indexidx_daily) monthly daily.resample(ME).sum() # 新版写法等价于 M monthly_qoq monthly.pct_change().mul(100)在最新的 Pandas 版本里M已经推荐改为MEMonth EndMS改为MS行为基本不变。类似的Q也逐渐被QE取代。如果你看到别人的老代码里写M新环境跑会出现FutureWarning。升级 Pandas 版本时这些警告要仔细看别直接忽略因为它们往往预示着语义的微小变化。5. 三千万行数据的性能优化从能跑到不卡顿前四节我们把时间序列分析的核心操作都过了一遍。实际工作里技术难点之外还有一个同样的痛点数据量大了之后怎么保证流程顺畅。我处理过一份超过3000万行的传感器日志时间字段精确到毫秒章节前面讲的每一个操作如果直接上默认设置全部会变成跑10分钟没反应。5.1 内存占用的第一道防线dtype 与类别型数据时间索引本身对这种海量数据的内存占用影响很大。datetime64[ns]类型在内存中占 8 字节一个元素3000万行就是约 240 MB——光索引就要吃这么多内存。如果你把时间列同时保留成字符串再存一份内存直接翻倍。优化路径是只保留必要列用更省的 dtype。比如订单金额这种正数浮点用float32能省一半内存状态字段比如已支付/未支付/已退款可以直接用category类型内存可以降一个数量级。df[状态] df[状态].astype(category) df[金额] df[金额].astype(float32)这一步做下来3000万行的 DataFrame 从几百 MB 降到几十 MB 是很正常的。内存小了后面所有操作的换页和缓存压力都小得多。5.2 日期范围过滤先缩小数据再计算时间序列分析有一个天然的优化诀窍先用布尔索引过滤出需要的日期范围再做重采样或窗口计算而不是全量数据直接跑。mask (df.index 2024-01-01) (df.index 2024-02-01) january df.loc[mask] weekly_january january.resample(W-FRI, closedleft, labelleft).sum()这种过滤逻辑本质上是把数据量先砍到只剩你需要的那一段。以前我为了图省事每次都对着全量数据resample后来发现某些操作的时间复杂度跟数据量呈正比而过滤之后甚至可以快几十倍。一个简单查询比如近三个月在几百万行的表上过滤前可能需要一两次 10 秒级等待过滤后往往是毫秒级返回。5.3 向量化操作与 dt 访问器把循环赶出代码时间序列分析里最常见的性能杀手是 Python 层级的for循环。比如你想提取每条记录所属的周几、是否周末、季度如果写成for i in range(len(df))再逐行判断3000万行就是3000万次 Python 循环跑一天都未必完。正确做法是用 Pandas 的向量化操作df[周几] df.index.dayofweek # 0周一, 6周日 df[小时] df.index.hour df[月份] df.index.month df[季度] df.index.quarter这些操作都是底层 C 或 NumPy 实现的性能差距是两个数量级。同理判断是否周末也不需要循环df[is_weekend] df.index.dayofweek.isin([5, 6])整个表达式直接操作整个 Series一次搞定。5.4 缓存中间结果与分批处理的实际经验最后分享一个工程经验。时间序列分析往往是多步流水线读取 → 清洗 → 去重 → 排序 → 重采样 → 滚动 → 输出。每一站在大数据量下都需要时间。如果每次重新跑脚本都得从头再来一遍那你多半会在等待中失去耐心。我的做法是中间结果落盘。把清洗和去重后的数据存成 Parquet 格式带压缩比 CSV 小很多下次直接读入。pd.read_parquet读取速度远超 CSV且能保留datetime64和category类型信息不用重新转换。# 清洗后落盘后续直接读取 df clean_time_series(raw_df) df.to_parquet(cleaned_orders.parquet) df pd.read_parquet(cleaned_orders.parquet)这一步看似无足轻重但对于每天都要更新报表的场景节省的时间非常可观。你可以把清洗和分析两个阶段分离清洗阶段一天只需要跑一次分析阶段则能频繁尝试不同的 window、不同的频率参数而每次尝试都不必重新清洗。6. 结合真实业务场景的完整案例代码到这里核心知识点都讲完了。为了把这些零散的内容串成一个开箱即用的完整流程我干脆写一个完整的实战案例。假设你手上有一份2024年第一季度门店销售数据包含门店ID、销售时间、销售额三个字段。目标有三按日汇总趋势、计算7日移动平均、按周对比销售额环比。import pandas as pd import numpy as np # 1. 模拟数据3家门店5分钟一笔记录 np.random.seed(0) dates pd.date_range(2024-01-01, 2024-03-31, freq5T) store_ids [A, B, C] records [] for store in store_ids: amount np.random.lognormal(mean3, sigma0.5, sizelen(dates)) records.append(pd.DataFrame({门店ID: store, 销售时间: dates, 销售额: amount})) df pd.concat(records, ignore_indexTrue) # 2. 标准起手式解析、索引、排序 df[销售时间] pd.to_datetime(df[销售时间]) df df.set_index(销售时间).sort_index() # 3. 按日汇总每个门店的销售额 daily df.groupby([门店ID, pd.Grouper(freqD)])[销售额].sum().unstack(level0) # 4. 对每个门店计算7日移动平均自然日窗口 daily_ma7 daily.rolling(window7D, min_periods3).mean() daily_ma7.columns [f{col}_MA7 for col in daily_ma7.columns] # 5. 合并原始日度数据和移动平均 result daily.join(daily_ma7) # 6. 按周汇总并计算每周环比 weekly df.groupby([门店ID, pd.Grouper(freqW-FRI, closedleft, labelleft)])[销售额].sum().unstack(level0) weekly_growth weekly.pct_change().round(4) * 100 weekly_growth.columns [f{col}_环比% for col in weekly_growth.columns] weekly_with_growth weekly.join(weekly_growth)这里要特意讲一下pd.Grouper(freqD)的用法。它允许你在groupby中直接用一个频率参数把时间索引分组——如果你需要按门店 × 天做分组聚合groupby([门店ID, pd.Grouper(freqD)])是比先resample再groupby更简洁的写法。同样的模式也适用于groupbyW-FRI代码语义清晰可读性很好。完整流程跑完之后result和weekly_with_growth就是可以直接写进报表或者可视化工具的两张表。这里每个步骤都对应前面章节讲过的知识点算是一个从数据到手到产出结果的全链路演示。结尾如果你只能记住三句话Pandas 处理时间序列的知识点非常多但真正决定成败的往往不是某个冷门参数而是数据是否在正确的形态上运行。我在实际工作中发现大部分代码没错但结果不对的案例最后都能归结到三个原因时间索引没设置、频率字符串语义理解错误、时区没有统一。把这三点刻在脑子里比记住一百个 API 都管用。如果让我给刚入手的同学一个建议不要一上来就追求把所有函数都背下来。先拿一份你自己的数据哪怕是模拟的从头到尾跑一遍设置索引 → 重采样 → 滚动窗口 → 合并报表这条完整链路。跑通之后再把每个环节拆开逐个替换参数去观察输出的变化——比如把closed从左改成右、把window从7改成7D看结果怎么变。这个过程比读任何教程都更能建立直觉。最后说一个我在实际工作中反复用的小技巧分析完一份时间序列数据之后顺手把关键的时间特征周几、小时、季度、是否为工作日全部提取出来存成一个结构化的特征表。这张表不仅是后续建模的输入也是你复盘这个指标为什么这周涨了的好帮手。时间序列并不只是画一张趋势图那么简单它背后的维度拆解和因果逻辑才是真正让人上头的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑