前段时间有位读者朋友问我一个关于如何处理时间序列数据中的缺失值问题,但是只在评论中简单回复了一下。
后来我想想,时间序列数据是一种非常常见的数据集,处理缺失值的问题可以单独聊一聊,于是,就整理了这篇文章。
我们拿到一份时间序列数据,第一步通常不是分析,而是确认它完不完整。
数值缺失好认:空值、NaN、占位符都摆在格子里。但时间序列最隐蔽的缺失是"某个时间点压根没出现",也就是行记录都没有,你连 isna() 都没法对它用。
按照时间序列采集的规律,我分成三种类型的缺失,一种比一种复杂,然后分别针对这三种情况给出对应的方案。
1. 三类数据,三种期望轴
按采集规律,时间序列基本逃不出这三类:
第一类是定期采集,比如气象站每小时记一次温度湿度。它有固定频率,期望轴就是一条铺满的等间隔时间线,缺了哪个点一眼能补。
第二类是复杂规律,典型是股票日 K 线。它名义上"一天一条",但周末、法定节假日、临时休市都不开,期望轴不是简单的日历日,得用交易日历算。
第三类是完全无规律,比如逐笔成交的交易、人工巡检记录。它没有固定频率,你根本造不出一条完整的期望轴,只能靠"相邻间隔该多长"和"应该在哪个时段出现"来反推缺失。
后面针对每类数据,跑一个实战,完整的测试代码文末共享。
2. 第一类:定期采集——用完整时间轴 reindex
以气象站为例。
假设每小时一条记录,但某天漏了 03:00、14:00、15:00 三个整点。
原始数据里这三个时间根本不存在,所以"缺失"不是 NaN,是"行没了"。
找法很直接:先把一整天按小时铺成 24 个时间点的期望轴,再用 reindex 把真实数据对上去。
对不上的位置自动变成 NaN,这些 NaN 对应的时刻就是要找的缺失。
full_idx = pd.date_range("2026-10-01 00:00", "2026-10-01 23:00", freq="h")
reindexed = df["temp"].reindex(full_idx)
missing = reindexed[reindexed.isna()].index
reindex 的好处是不假设原始数据干净:如果某小时出现多条(设备重发),先把数据 resample("h").mean() 聚合成一条再对齐,逻辑不变。
完整脚本见 find_missing_regular.py。
跑出来的结果:
实际收到的记录数: 21 (应有 24 条)
实际时间范围: 2026-10-01 00:00:00 → 2026-10-01 23:00:00
缺失的时间点:
- 2026-10-01 03:00:00
- 2026-10-01 14:00:00
- 2026-10-01 15:00:00
聚合后仍有缺失的时间点数: 3
21 条对 24 条,正好少 3 条;reindex 后报出来的三个缺失时刻,和构造时删掉的 03:00 / 14:00 / 15:00 完全对上。
最后一行说明即使先做了小时聚合,缺失数量依旧是 3,方法在脏数据下也站得住。
3. 第二类:复杂规律——用交易日历
股票日 K 线不能拿自然日当期望轴,否则周末和节假日会被误判成"缺失"。
正确做法是先造一条交易日历:工作日减掉法定节假日。
class SimpleCNCalendar(AbstractHolidayCalendar):
rules = [Holiday(f"NationalDay{i}", month=10, day=i) for i in range(1, 9)] # 演示用:国庆黄金周
bday = pd.date_range(start, end, freq="B") # 工作日
holidays = SimpleCNCalendar().holidays(start, end) # 节假日
trading_days = bday.difference(holidays) # 交易日期望轴
有了交易日期望轴,把真实 K 线索引和它做集合差,期望里有、实际没有的,就是缺失的交易日:
missing = trading_days.difference(kline.index)
这里要分清两类"没数据":
- 周末和法定节假日本来就不该有记录,它们压根不在交易日期望轴里,所以不算缺失;
- 只有"交易日里却没出现"的那几天,才是真正漏掉的。
实战别自己手列节假日,调休最容易漏,直接用维护好的日历库(如 exchange_calendars,或 akshare、tushare 的交易日接口)。
完整脚本见 find_missing_trading.py。
跑出来的结果:
期望交易日数: 5
实际收到 K 线条数: 3
缺失的交易日:
- 2026-09-30
- 2026-10-09
期望 5 个交易日,实际只收到 3 条 K 线,集合差直接给出缺的 9 月 30 日和 10 月 9 日这两天。
注意这段时间里的周末(10/3、10/4、10/10、10/11)和国庆黄金周(10/1–10/8)都没被算进"缺失"——这正是用交易日历、而不是自然日的关键价值。
4. 第三类:完全无规律——靠间隔阈值和时段窗口
逐笔成交、巡检记录这类没有固定频率,你造不出完整期望轴。
思路换成两个:
一是最大间隔阈值。相邻两条记录之间的间隔,正常应该在某个范围内;一旦某次间隔超过阈值,说明中间那段没采到,是缺失。
MAX_GAP = pd.Timedelta(minutes=2)
gaps = df.index.to_series().diff()
broken = gaps[gaps > MAX_GAP] # 间隔超阈值的断点
二是交易时段窗口。成交只发生在交易时段内(如 9:30–11:30、13:00–15:00)。把数据按 between_time 切到时段里,再在时段内部看最大间隔,能避免把"休市"误判成缺失。
完整脚本见 find_missing_irregular.py,里面两种方法都跑了,结果如下:
方案 A:间隔超过阈值(0 days 00:02:00)的断点:
- 在 2026-10-23 09:41:55 之前断了 0 days 00:08:45
- 在 2026-10-23 11:29:10 之前断了 0 days 01:45:20
- 在 2026-10-23 13:01:05 之前断了 0 days 01:31:55
- 在 2026-10-23 14:59:40 之前断了 0 days 01:58:35
方案 B:各交易时段内部最大间隔:
- 时段 09:30-11:30:7 笔,最大间隔 0 days 01:45:20,有缺失
- 时段 13:00-15:00:2 笔,最大间隔 0 days 01:58:35,有缺失
方案 A 把 09:41 之前那次 8 分 45 秒的间隔准确抓了出来——那正是构造时故意留空的 09:34~09:41 段。
但它也把 11:29→13:01(午休)、13:01→14:59(下午长时间无成交)一并报成了"断点",因为这些超出了 2 分钟阈值。
这正是方案 B 存在的意义:只看交易时段内部,能自动跳过午休这种合法空档,把"真缺失"和"休市"分开。
5. 三种方案放在一起看
三类的差别其实就在一件事:期望的时间轴怎么来。
| 类型 | 期望轴来源 | 找缺失的方法 |
|---|---|---|
| 定期采集 | 固定频率铺满 | reindex 对齐,NaN 即缺失 |
| 复杂规律 | 交易日历 | 集合差,缺的交易日 |
| 无规律 | 无固定轴 | 间隔阈值 / 时段窗口 |
共同的前置动作都一样:先把时间列转成 DatetimeIndex 并排序,再谈找缺失。
排序这步最容易被忘,但数据乱序时 reindex 和集合差都会出错。
文中使用的代码共享在:url11.ctfile.com/d/45455611-…
感兴趣的朋友欢迎下载了运行看看效果。