☰
时间序列 DataFrame 实战:时间索引、重采样、滚动窗口与特征工程
2026/10/26 13:46:07 网站建设 项目流程

做时间序列这摊活儿,我见过太多人卡在同一个地方:数据本身没毛病,业务逻辑也想清楚了,一到写代码就被object类型的时间列、resample之后错位的索引、rolling窗口里悄悄混进来的未来数据按在地上摩擦。这篇是 DataFrame 系列的收口篇,专讲时间序列 DataFrame,也是往机器学习方向走的必经一关——你后面做的滞后特征、滑动统计、周期编码、训练集/验证集切分,几乎全部建立在这一篇讲到的东西上面。适合已经能熟练写groupby、merge,但对时间轴上的切片、重采样、窗口对齐还没有形成稳定肌肉记忆的读者。我不会从"什么是 Series"讲起,直接进正题:时间列怎么"扶正"、DatetimeIndex 建起来之后怎么用、resample的规则码与边界到底怎么咬合、窗口函数怎么用才不泄漏、时间特征怎么编码才不坑模型。中间穿插我自己踩过的几个坑,包括那个让我排查了整整一个下午的groupby + resample索引错位。

1. 时间列的"身份"问题:object、datetime64 与带时区类型

时间序列 DataFrame 的第一步从来不是画图,也不是 resample,而是确认你手上那列到底是什么类型。这件事听起来无聊,但它决定了后面九成的操作能不能顺利跑通。df.dtypes里如果时间列显示的是object,那它在 pandas 眼里就是一串普通字符串,你按它排序,走的是字典序;你按它切片,走的是字符串前缀匹配;你按它分组,走的是去重计数。表面上都能出结果,结果对不对是另一回事。

1.1 为什么astype转换时间列经常"看似成功实则失败"

新手最常用的写法是df['ts'].astype('datetime64[ns]')。它能跑通的时候确实快,但它对输入格式极其苛刻,基本只认2024-01-05、2024-01-05 13:20:00这类标准化写法。一旦遇到2024/01/05、05-01-2024、2024年1月5日,或者混进了-、空字符串、N/A这类脏值,它要么直接抛ValueError,要么在某些版本里给你一堆NaT还不报错——后者更危险,你以为转换成功了,其实整列都成了空值,等到 resample 的时候才发现所有分箱都是 0。

我现在的固定动作是pd.to_datetime加上format和errors两个参数,一个都不省:

import pandas as pd df = pd.read_csv("orders.csv") df["ts"] = pd.to_datetime( df["ts"], format="%Y-%m-%d %H:%M:%S", errors="coerce", ) bad = df["ts"].isna().sum() print(f"解析失败行数: {bad}")

format显式指定之后有两个好处:一是解析速度快一个量级,因为 pandas 不用逐行猜格式(不开format时会走dateutil的启发式解析,几十万行能慢到让你怀疑机器卡了);二是只要有一条记录不符合格式,errors="coerce"会把它变成NaT而不是让整批任务崩掉,你事后统计isna()就能把脏数据捞出来单独看。

这里有个细节值得单独提一句:errors有三个取值,raise(默认,遇错就抛)、coerce(转成 NaT)、ignore(原样返回)。ignore我是从来不用的,因为它返回的是 object 类型,等于你白转一次。

1.2 精度、单位和时区:三个容易在后期爆炸的隐患

datetime64[ns]的ns是纳秒。pandas 早期版本把时间统一压在纳秒精度上,这个表示范围大概只覆盖 1677 年到 2262 年。如果你处理的是传感器时间戳(微秒或毫秒级)、或者某些设备给的是 Unix 时间戳整数,直接用pd.to_datetime(col)而不指定unit,会被当成纳秒解释,出来的时间能偏到 1970 年附近。这种错误肉眼极难发现,因为它不报错、不产生 NaT,就是静静地错。

# 毫秒时间戳 df["ts"] = pd.to_datetime(df["ms"], unit="ms") # 秒时间戳 df["ts"] = pd.to_datetime(df["sec"], unit="s")

再一个是时区。带时区和不带时区的时间序列在 pandas 里是两种不同的 dtype:datetime64[ns]和datetime64[ns, Asia/Shanghai]。这两者之间做比较、做merge、做concat,行为是不一样的,混在一起经常抛出TypeError: Cannot compare tz-naive and tz-aware datetime-like objects。我的做法是统一在数据入库时就把时区处理干净:如果是单一时区的业务数据,tz_localize到本地时区,然后立刻tz_convert("UTC")存起来,展示层再转回来。

df["ts"] = ( df["ts"] .dt.tz_localize("Asia/Shanghai", ambiguous="NaT", nonexistent="NaT") .dt.tz_convert("UTC") )

注意ambiguous和nonexistent两个参数。夏令时切换的那一小时里,本地时间会出现重复(fall back)或者缺失(spring forward),不处理的话tz_localize会直接抛异常。虽然国内业务碰不到这个问题,但只要你的数据里混进了海外节点的时间戳,就一定会在某个凌晨出事。

最后说精度对齐。datetime64[ns]在 pandas 2.0 之后其实已经支持非纳秒单位了(s、ms、us),高精度场景下换用更粗的单位能省不少内存。几十亿行的时间戳从ns降到s,内存能砍掉接近一半。这个优化在大规模日志场景里非常划算,但前提是你确定业务上不需要亚秒精度。

2. DatetimeIndex 建好之后,索引切片才真正"值钱"

set_index把时间列变成DatetimeIndex,这是整个时间序列操作的起点。很多人止步于"设了索引",然后继续用布尔条件过滤,这相当于买了辆车天天推着走。DatetimeIndex 真正的价值在于它支持语义化的切片,写法短、执行快、可读性强。

2.1 部分字符串索引的边界规则,别靠猜

把时间列设成索引之后,下面这几种写法都是合法的:

df = df.set_index("ts").sort_index() df.loc["2024-03"] # 整个三月 df.loc["2024-03-15"] # 单独一天 df.loc["2024-03-01":"2024-03-31"] # 区间,两端都包含 df.loc["2024-03-15 09":"2024-03-15 18"] # 具体到小时

这里有两个必须记住的规则。第一,切片两端都包含,这一点和普通的iloc切片完全不同,别下意识地写成"2024-03-01":"2024-03-30"还以为拿到了 31 号。第二,部分字符串匹配走的是前缀,df.loc["2024-03"]匹配的是所有以2024-03开头的索引值,所以它天然等价于整个三月,但如果你的索引里混进了2024-03开头的异常数据,也会被一并捞出来。

还有一个坑:用.loc做时间切片的前提是索引单调递增。没排序的 DatetimeIndex 上去切片,轻则拿到不完整结果,重则直接抛UnsortedIndexError。所以set_index之后紧跟一句.sort_index()应该是条件反射,我甚至会在团队代码规范里把它写成硬性要求。

2.2at_time、between_time与loc的分工,别一把梭

这三个东西经常被混用,但边界很清晰:

方法作用对象典型场景是否要求索引有序
loc["2024-03-15"]具体日期/区间按天、按月取数是
at_time("09:30")每天中的某个时刻取每天开盘瞬间的那条记录否
between_time("09:30", "15:00")每天中的时段区间只保留交易时段内的记录否

between_time有个参数inclusive,默认是"both",可以改成"neither"、"left"、"right"。这个参数在处理"包含收盘那一秒但不含开盘那一秒"这类边界时很关键,如果你们的业务口径是左闭右开,记得显式写出来,不然默认值会让你多算几条数据,而且这种偏差在总量统计里几乎看不出来。

session = df.between_time("09:30", "15:00", inclusive="left")

另外,between_time和at_time都不要求索引有序,因为它们是按时间分量(时分秒)过滤,不是按位置或区间做二分查找。这一点在数据还没排序时特别有用,可以先用它们粗筛,再排序。

2.3 索引未排序时的应急处理

有时候数据量太大,sort_index()本身就慢。如果你的目标只是"取某个日期范围内的所有行",而不关心顺序,用布尔条件会比切片快:

mask = (df.index >= "2024-03-01") & (df.index < "2024-04-01") sub = df[mask]

这个写法对无序索引是安全的,而且表达的范围语义是左闭右开,正好适合做训练集/测试集的切分。我在做时间序列交叉验证时更偏爱这种写法,因为它不会因为"某个索引值恰好落在边界上"而产生歧义。

3.resample:时间序列 DataFrame 的核心部件

resample是这一篇的重头戏,它是把原始粒度的时间序列转换成目标粒度的唯一正确姿势。groupby也能做类似的事,但你需要自己构造一个时间分箱列,代码更长、更容易出错,而且会丢掉 DatetimeIndex 这个"时间轴身份"。

3.1 规则码(offset alias)与closed、label的咬合关系

规则码是resample的第一个参数,常见的有:

规则码含义备注
D自然日不受时区影响,按日历切
h/H小时新版本推荐小写h,大写已废弃
min/T分钟T已废弃,用min
W-MON每周,以周一为锚点不写锚点默认周日
MS月初M是月末,两者完全不同
QS季度初同理Q是季末
15min15 分钟支持任意整数倍

M和MS这个区别坑过不少人。用resample("M")得到的标签是每个月最后一天,用MS得到的是每月第一天。如果你后面要把这个结果和其他按"月初"对齐的表做 merge,标签选错就全错位了。

再说closed和label。默认closed="left"、label="left",意思是区间左闭右开,标签取左端点。举个例子,按天重采样时,2024-03-01这个标签代表的是[2024-03-01 00:00, 2024-03-02 00:00)这个区间。如果你改成closed="right"、label="right",那就变成(2024-03-01 00:00, 2024-03-02 00:00],标签是右端点。

这个选择的实际影响在日终结算类业务里非常大。比如你统计每日订单,如果业务口径是"当日 00:00 到 23:59:59.999 都算今天",那默认的left就对了;但如果口径是"从昨天晚上 18:00 到今天早上 6:00 算一个班次",你就必须显式指定closed/label,并且在跨月的第一天特别小心,因为区间的端点会跨到另一个月份里去。

daily = df.resample("D", closed="left", label="left").agg( orders=("order_id", "count"), gmv=("amount", "sum"), )

3.2 多列异质聚合与ohlc

真实业务里,一个 DataFrame 里往往同时存着金额、数量、状态等多列,它们的聚合方式完全不同。agg传字典是最干净的写法:

agg_map = { "amount": "sum", "qty": "sum", "price": "max", "order_id": "count", } res = df.resample("1h").agg(agg_map)

用NamedAgg的写法更好读,还能顺手重命名列,避免后面反复rename:

res = df.resample("1h").agg( gmv=pd.NamedAgg(column="amount", aggfunc="sum"), high=pd.NamedAgg(column="price", aggfunc="max"), low=pd.NamedAgg(column="price", aggfunc="min"), )

如果做的是行情或传感器数据,ohlc()是现成的:

bars = df["price"].resample("5min").ohlc()

注意ohlc()只能对 Series 用,传 DataFrame 会报错;而且新版连 Series 的.ohlc()也标了弃用,稳妥的写法是直接用agg(["first", "max", "min", "last"]),语义一样、兼容性更好:

bars = df["price"].resample("5min").agg(["first", "max", "min", "last"])

3.3 上采样时"凭空造出来的数据"问题

下采样(高粒度转低粒度)是聚合,逻辑清楚。上采样(低粒度转高粒度,比如日频转小时频)就危险了——你是在无中生有。

hourly = daily.resample("1h").asfreq() # 全是 NaN

asfreq()只是把时间轴拉密,不填值。如果你直接用.ffill()一路向前填,等于告诉模型"这一小时的值和上一小时一样",而实际上你可能根本不知道这一小时发生了什么。在预测类任务里,这种填充会引入大量自我复制的伪信号,模型会误认为序列非常平滑,然后在真实数据上表现崩盘。

我的处理原则是:上采样只在两种情况下做——要么确认业务上"值在下一个观测到来前保持不变"(比如配置项、库存快照),要么把"是否插补"本身做成一个特征列。

hourly = daily.resample("1h").asfreq() hourly["was_filled"] = hourly["value"].isna().astype(int) hourly["value"] = hourly["value"].ffill(limit=3) # 最多补 3 小时

limit参数非常关键。不限制的 ffill 会在长假期、长停机期间一路填下去,直接污染整段序列。

4.rolling/expanding/ewm:窗口函数的对齐与泄漏红线

窗口统计是时间序列特征工程里产出最高的一类特征。移动均值、移动标准差、过去 N 天的最大值,几乎每个模型都会用到。但这里也是数据泄漏最容易发生的地方,而且往往悄无声息。

4.1 窗口方向、min_periods与center的语义

rolling(window=n)默认是向后看(trailing window),即当前行包含自己在内的前 n 行。这个默认值是对的,也是安全的。但center=True会把它变成居中窗口,也就是当前行会用到后面 n/2 行的数据。这个参数在做平滑可视化时很好看,但绝不能出现在建模特征里,否则相当于你提前知道了未来。

min_periods决定窗口里至少要有几个非空值才输出结果。默认等于window,意味着序列开头的 n-1 行全是 NaN。做特征时会白白丢掉前面一大段,很多人在这一步把数据砍掉小半年还不自知。

df["ma7"] = df["value"].rolling(7, min_periods=3).mean()

我这里设成 3,是因为序列开头前 3 天数据比较全,从第 3 天起出的均值虽然样本少、噪声大,但比丢掉好。代价是这些位置的均值稳定性不如后期,如果你后面要做标准化,记得它们的方差和后面不一样。这个问题在生成特征后做StandardScaler时特别容易出现——早期几行的 z-score 会异常大或者异常小。

再补充一个容易忽略的点:rolling的window参数除了整数,还能传时间偏移字符串,比如rolling("7D")。这个写法的好处是它能处理时间不连续的序列——中间缺了几天,整数窗口会把缺的那几天忽略掉、直接往前数 7 条,而"7D"是严格按时间跨度算的,缺的天就真的是缺的。

df["ma_7d"] = df["value"].rolling("7D", min_periods=3).mean()

做传感器、日志这类采样间隔不均匀的数据时,一定要用时间偏移窗口,别用整数窗口。

4.2shift/diff/pct_change:滞后特征与那条不能碰的线

shift是构造滞后特征的主力。df["value"].shift(1)拿到上一行的值,shift(-1)拿到下一行的值。方向搞反是新手最常见的错误,而shift(-1)用在特征里就是标准的未来信息泄漏。

df["lag_1"] = df["value"].shift(1) df["lag_7"] = df["value"].shift(7) # 当前值相对上一期的变化 df["chg"] = df["value"].diff(1) # 变化率 df["pct"] = df["value"].pct_change(fill_method=None)

pct_change在新版本里必须显式传fill_method,否则会有一条警告。默认行为是先把 NaN 前向填充再算变化率,这会让某些位置的"变化率"变成 0(因为填充值和原值一样),这个 0 是假的。设成None之后,只要前一期缺失就直接给 NaN,语义更干净。

真正要小心的场景是先 resample 再 shift。举个例子,你按天聚合之后可能得到一条"今天 23:59 的累计值",然后shift(1)拿到"昨天 23:59 的累计值"。看起来只是滞后一天,但如果这个累计值是在当天收盘时才统计出来的,那么"今天的特征"其实包含了今天全天的信息。把这个特征喂给"预测今天"的模型,等于开卷考试。

我的做法是统一在特征侧做一次截止时间的审计:对每一个时间戳 t,列出它用到的所有原始数据的时间戳,最大值必须严格小于 t。这个审计用代码可以半自动化:

feat_cols = ["lag_1", "ma7", "ma_7d"] for col in feat_cols: print(col, df[col].shift(-1).notna().sum())

做法是把特征整体往前错一位再看有没有值——如果错位后还有大量非空,说明这些特征可能已经"看到"了当前行。这只是个粗筛,但能抓住大部分方向写反的低级错误。

4.3expanding与ewm:全历史统计和指数衰减

expanding()是从序列开头一直累加到当前行,常用于"累计转化率""至今为止的最大值"这类特征。

df["cum_max"] = df["value"].expanding().max() df["cum_mean"] = df["value"].expanding(min_periods=10).mean()

ewm是指数加权,参数里最容易被忽略的是adjust。adjust=True时,早期数据权重更大,结果对小样本更敏感;adjust=False时走递归更新,权重衰减更均匀,长序列上更稳定。做实时特征时我一般用adjust=False,因为它的更新是严格递推的,线上只要保存上一期的ewm值就能续算,不用回放全部历史。

df["ewm_12"] = df["value"].ewm(span=12, adjust=False, min_periods=5).mean()

span、halflife、alpha三个参数是等价的三个入口,别同时传,否则报错。我习惯用span,因为它直观对应"大约看多少个周期"。

5. 时间特征工程:从dt访问器到周期编码

模型吃不了时间戳,只能吃数值。怎么把一个2024-03-15 14:32:07变成一个或几十个有意义的数值列,这一步直接决定模型能不能学到季节性。

5.1dt访问器能直接拿到的特征清单

只要列是 datetime 类型,dt访问器就是随即取用:

表达式取值建模意义
dt.year/dt.month/dt.day数值年级别趋势、月度季节性
dt.dayofweek0=周一, 6=周日工作日与周末行为差异
dt.hour/dt.minute数值日内周期
dt.quarter1-4季度效应
dt.is_month_endbool结算日、报表日效应
dt.dayofyear1-366年周期定位
dt.days_in_month28-31月末天数差异
df["hour"] = df.index.hour df["dow"] = df.index.dayofweek df["is_weekend"] = (df.index.dayofweek >= 5).astype(int) df["is_month_end"] = df.index.is_month_end.astype(int)

is_month_end这类布尔标记在金融、零售场景里价值极高,因为月末、季末、年末经常有特殊结算逻辑,用树模型能轻松捕捉到。我通常会把is_month_start、is_month_end、is_quarter_end都加上,成本几乎为零。

isocalendar()是个进阶用法,能一次拿到 ISO 周数、ISO 年、ISO 星期:

iso = df.index.isocalendar() df["iso_week"] = iso["week"].to_numpy() df["iso_year"] = iso["year"].to_numpy()

注意要用.to_numpy()或者先转成 DataFrame 再赋值,直接对齐索引可能会错位。

5.2 周期性特征的 sin/cos 编码:为什么不能直接把小时数喂给模型

hour的取值是 0 到 23,线性模型看到的是"23 比 0 大得多",但实际上一小时之后又会回到 0,23 和 0 在语义上是紧挨着的。这种"首尾相接"的变量叫周期变量,直接当连续值用,会人为制造一条并不存在的断层。

正确做法是把它投影到单位圆上:

import numpy as np df["hour_sin"] = np.sin(2 * np.pi * df.index.hour / 24) df["hour_cos"] = np.cos(2 * np.pi * df.index.hour / 24)

这样 23 点和 0 点在二维平面上的距离就很近,而 0 点和 12 点的距离最远,完美对应现实。同理可以处理星期(周期 7)、月份(周期 12)、一年中的第几天(周期 365 或 366)。

df["dow_sin"] = np.sin(2 * np.pi * df.index.dayofweek / 7) df["dow_cos"] = np.cos(2 * np.pi * df.index.dayofweek / 7)

树模型其实能从原始数值中自己学出这种关系,但成本更高、需要更多样本。线性模型、神经网络则几乎必须显式编码,否则会学到一条完全不合理的趋势。

有一点要提醒:sin/cos 编码之后,hour原始值最好保留一份。因为有些关系不是周期的,而是有序的——比如"凌晨 3 点"在业务上确实比"上午 10 点"更特殊,这个语义用 sin/cos 表达不出来,用原始小时值反而更直接。

5.3 不规则时间间隔:asfreq要不要先补全

真实数据很少是严格等间隔的。设备可能掉线、订单可能某几个小时没有、日志可能集中在某个时段爆发。而绝大多数时间序列模型(ARIMA、LSTM、甚至简单的 lag 特征)都隐含假设了"等间隔采样"。

full_idx = pd.date_range(start=df.index.min(), end=df.index.max(), freq="1h") df = df.reindex(full_idx)

reindex会把缺失时刻补成 NaN,但不会告诉你哪些是真缺失、哪些是"这天本来就没业务"。所以补完之后最好再加一列标记:

df["is_observed"] = df["value"].notna().astype(int)

对缺失值的处理,我一般会分层:短缺口(1-2 个点)用interpolate(method="time"),中缺口(3-24 个点)用ffill(limit=...)加标记,长缺口保留 NaN 交给模型自带缺失处理,或者干脆切分成段。这里最容易犯的错是"一刀切全 ffill",结果模型学到的全是平坦段落。

df["value_interp"] = df["value"].interpolate(method="time", limit=2) df["value_ffill"] = df["value"].ffill(limit=24)

method="time"和默认的"linear"在等间隔数据上结果一样,但在不等间隔数据上差别很大——"time"会按实际时间长度做加权插值,这才是物理意义上正确的。

6. 那次让我排查了一下午的groupby + resample索引错位

这部分是我个人踩坑记录的复盘,因为它的排查过程里有好几个可以复用的判断节点。

6.1 现象:分组之后的结果对不上号

某个批次需要给几百个实体各算一套日频特征,代码大概是:

res = df.groupby("entity_id").resample("D").agg( v_sum=("value", "sum"), v_cnt=("value", "count"), )

跑出来的res索引是 MultiIndex,(entity_id, date)两层。接着我把它和另一张按entity_id维度构造的表做join,结果整张表几乎全空。当时第一反应是entity_id类型不一致,查了发现两边都是int64。

6.2 排查链路:从索引层级到对齐方式

第一步,打印res.index,看层级名称和顺序:

print(res.index.names) # ['entity_id', 'ts']

第二步,看参照表:

print(ref.index.names) # ['entity_id', 'date']

名称不一样,但join是按位置对层级的,名称不匹配不一定是致命问题。真正的问题在于:res的第二层索引里有大量"该实体当天没有数据"的日期吗?答案是有的——resample把每个实体都补齐到了全局时间跨度,很多实体在某些天是全 0 记录。这些行在ref里并不存在,join之后自然对不上。

第三步,确认缺失情况:

print(res["v_cnt"].eq(0).mean()) # 0.61

六成是空箱。这就是根因:resample对每个分组都会生成完整的日期网格,业务上"这天没数据"和"这天数据为 0"是两回事,而resample默认把它们都变成了 0。

6.3 修复:保留"是否为空箱"这一层信息

修复思路是在聚合时用count作为判空依据,聚合后把v_cnt == 0的位置重新设回 NaN:

res = df.groupby("entity_id").resample("D").agg( v_sum=("value", "sum"), v_cnt=("value", "count"), ) res.loc[res["v_cnt"] == 0, "v_sum"] = float("nan")

然后不要急着join,先把第二层索引统一成一个普通的日期列,再用明确的on参数做 merge,比多级索引的join可控得多:

flat = res.reset_index().rename(columns={"ts": "date"}) merged = flat.merge(ref, on=["entity_id", "date"], how="left")

这个坑的核心教训是:多级索引的自动对齐很聪明,但聪明的东西出问题时最难查。凡是涉及分组后重采样的结果,我现在一律先reset_index()拍平,再显式指定 join 键,多写一行,省一下午。

7.Grouper、merge_asof与跨表时间对齐的实用组合

时间序列很少孤立存在,总要和其他表对齐。这里说两个高频组合。

7.1 别急着groupby + resample,先看pd.Grouper

groupby("entity_id").resample("D")会产生上面说的 MultiIndex。如果只是想"按实体按天聚合、保持扁平结构",用pd.Grouper更省事:

flat = df.groupby(["entity_id", pd.Grouper(key="ts", freq="D")]).agg( v_sum=("value", "sum") ).reset_index()

结果是标准的扁平 DataFrame,entity_id和ts都是普通列,后续 merge、写库、转 parquet 都更顺手。Grouper还支持closed、label、dropna等参数,行为和resample一致。

7.2merge_asof:把"最近一次的快照值"贴到事件表上

这个函数是为时间序列对齐量身定做的。典型场景:主表是随时产生的事件流,副表是每小时打一次的价格快照,你要给每个事件贴上"当时最新的价格"。

prices = prices.sort_values("snapshot_ts") events = events.sort_values("event_ts") out = pd.merge_asof( events, prices, left_on="event_ts", right_on="snapshot_ts", direction="backward", tolerance=pd.Timedelta("2h"), )

几个必须记住的点:

  • 两个表都必须按各自的 key 排序,不排序直接抛异常。
  • direction="backward"表示取"小于等于左表时间"的最近一条,这才是防止未来信息泄漏的正确方向。用"forward"就是从未来取,除非业务上明确是"下一笔报价",否则别用。
  • tolerance是安全阀。不加的话,如果某个价格快照离事件非常远(比如停机三天),依然会被贴上,这个值就失真了。加一个合理的容差,超出的地方留 NaN,让模型自己判断。
  • 两边的 key 必须是同一种时间类型,带不带时区要一致,否则直接报错。

我在一个日志关联的场景里用过这个组合:事件表几百万条,快照表几万条,merge_asof跑完不到一秒。同样的逻辑用reindex(method="ffill")加笛卡尔积去写,内存直接爆掉。

7.3 时区对齐后再合并

如果一边是 UTC、一边是本地时间,merge_asof会直接拒绝。处理办法只有一个:统一转成 UTC 再合并,不要试图靠"看起来差不多"来蒙混。

events["event_ts"] = pd.to_datetime(events["event_ts"]).dt.tz_localize("UTC") prices["snapshot_ts"] = ( pd.to_datetime(prices["snapshot_ts"]) .dt.tz_localize("Asia/Shanghai") .dt.tz_convert("UTC") )

合并完之后如果需要在报表里展示本地时间,再tz_convert回去,不要在中间环节来回转。

8. 收尾前的一点个人心得

写到这里,这个系列关于 DataFrame 的部分基本可以收了。回看这些年用 pandas 处理时间序列,真正让我卡住的从来不是某个函数不会用,而是类型没对齐、索引没排序、窗口方向搞反这三件事反复出现。它们不像语法错误那样会立刻报错,而是以一种"结果看起来挺正常但就是对不上"的方式持续消耗时间。

我的建议是:拿到任何带时间列的数据,先做三件事——print(df.dtypes)确认时间列的类型、print(df.index.is_monotonic_increasing)确认排序状态、print(df["ts"].dt.tz)确认时区信息。这三行加起来不到两秒,但能提前挡掉后面八成的诡异问题。另外,如果数据要进模型,务必在特征生成之后做一次泄漏审计,宁可多花十分钟验证,也别在模型上线之后才发现指标好得不像话。

还有一个小技巧值得分享:做时间序列调试时,把索引截取一小段(比如df.loc["2024-03-01":"2024-03-03"])打印出来,肉眼逐行核对 resample 和 rolling 的结果,比看全局统计量有效得多。分箱边界、窗口对齐这类问题,在整体数据上往往被平均掉,只有在小样本上才暴露得一清二楚。后续如果你想继续深挖,可以把这套东西接到parquet分区存储和增量更新上去,那是另一条值得走的路线。

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

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

立即咨询