RSS
菜单
全部文章快讯开发科技深度热点

第 20 章 时间序列高级:工作日历、节假日、时区与频率转换

内容摘要

[第 14 章](14-时间序列分析.md)讲了时间序列的基础(resample/rolling/shift)。本篇深入进阶:**工作日与业务日历、节假日处理、时区与夏令时、Period 区间、频率转换、时间特征工程**。这些是金融、运营、排班等真实场景的高频需求。

第 14 章讲了时间序列的基础(resample/rolling/shift)。本篇深入进阶:工作日与业务日历、节假日处理、时区与夏令时、Period 区间、频率转换、时间特征工程。这些是金融、运营、排班等真实场景的高频需求。

目录

1. 业务日与工作日历

2. 节假日处理

3. 频率转换进阶:asfreq / 高低频互转

4. 时间区间:Period 与 PeriodIndex

5. 时区与夏令时

6. between_time / at_time / truncate

7. 时间特征工程

8. 滞后特征与窗口特征综合

9. 实战:交易日历下的业务指标

10. 常见坑与注意事项

11. 本章小结与练习


1. 业务日与工作日历

默认 date_range 按自然日(D)生成;金融/业务场景需要工作日(B)甚至自定义工作日历:

import pandas as pd

# 工作日(周一至周五)
workdays = pd.date_range("2025-01-01", periods=10, freq="B")
print(workdays)
# DatetimeIndex(['2025-01-01', '2025-01-02', '2025-01-03', '2025-01-06',
#                '2025-01-07', '2025-01-08', '2025-01-09', '2025-01-10',
#                '2025-01-13', '2025-01-14'], dtype='datetime64[ns]', freq='B')
# 注意跳过了 1/4、1/5(周六日)

# 月末工作日
pd.date_range("2025-01-01", periods=4, freq="BM")     # 月末最后一个工作日
pd.date_range("2025-01-01", periods=4, freq="BMS")    # 月初第一个工作日

# 每周几
pd.date_range("2025-01-01", periods=4, freq="W-MON")  # 每周一
pd.date_range("2025-01-01", periods=4, freq="W-FRI")  # 每周五

自定义工作日(CustomBusinessDay)

某些地区工作周不是"周一至周五"(如中东周五周六休):

from pandas.tseries.offsets import CustomBusinessDay

# 周五、周六休息(周日-周四工作)
cbd = CustomBusinessDay(weekmask="Sun Mon Tue Wed Thu")
print(pd.date_range("2025-01-01", periods=8, freq=cbd))
# 工作日偏移:往后推 3 个工作日
from pandas.tseries.offsets import BDay

ts = pd.Timestamp("2025-01-03")        # 周五
print(ts + BDay(1))                    # 2025-01-06(跳过周末)
print(ts + BDay(3))                    # 2025-01-08
print(ts - BDay(2))                    # 2025-01-01

2. 节假日处理

中国/美国节假日可以直接用内置日历,也可以自定义:

# 自定义节假日列表
from pandas.tseries.holiday import (
    Holiday, DateOffset, weekday, MO, FR,
    AbstractHolidayCalendar,
)

class CNHolidayCalendar(AbstractHolidayCalendar):
    """示例:元旦、国庆(简化版,仅演示)"""
    rules = [
        Holiday("元旦", month=1, day=1),
        Holiday("国庆", month=10, day=1),
    ]

# 生成 2025 年节假日
cal = CNHolidayCalendar()
holidays = cal.holidays(start="2025-01-01", end="2025-12-31")
print(holidays)
# DatetimeIndex(['2025-01-01', '2025-10-01'], dtype='datetime64[ns]', freq=None)

# 用节假日日历定义"业务日"(跳过节假日)
from pandas.tseries.offsets import CustomBusinessDay
cbd = CustomBusinessDay(calendar=cal)
print(pd.date_range("2025-01-01", periods=5, freq=cbd))
# 2025-01-02, 01-03, 01-04, 01-05, 01-06 (跳过了 1/1 元旦)

内置节假日(美国市场示例)

from pandas.tseries.holiday import USFederalHolidayCalendar

us_cal = USFederalHolidayCalendar()
print(us_cal.holidays(start="2025-01-01", end="2025-12-31"))
# 包含新年、马丁路德金日、总统日、阵亡将士纪念日、独立日、劳动节、哥伦布日、老兵节、感恩节、圣诞节等
生产环境可用 workalendar、chinese_calendar 等第三方库获取中国完整节假日与调休安排。

3. 频率转换进阶:asfreq / 高低频互转

3.1 asfreq:不聚合的纯频率转换

np.random.seed(0)
s = pd.Series(np.random.rand(5), index=pd.date_range("2025-01-01", periods=5, freq="D"))
print(s)

# 升采样:插值
print(s.asfreq("h", method="ffill").head(5))     # 按小时前向填充

# 降采样到周:asfreq 只"取点"(每周五?)不聚合
print(s.asfreq("W").head(5))
resample 会聚合,asfreq 不聚合。降采样时 resample 更常用;升采样时 asfreq(method=) 更直接。

3.2 高低频互转 + 对齐

# 日 -> 月(聚合)
daily = pd.Series(np.random.randint(10, 50, 90),
                  index=pd.date_range("2025-01-01", periods=90, freq="D"))
monthly = daily.resample("M").sum()

# 月 -> 日(前向填充回填到每月)
daily_back = monthly.resample("D").ffill()

# 多个序列频率对齐:reindex 到统一频率
common = daily.index.to_period("M").to_timestamp("M")

3.3 使用 Grouper 统一频率分组

# 不同频率的数据合并统计
df = pd.DataFrame({
    "日期": daily.index,
    "值": daily.values,
})
# 按周分组统计(等价 resample)
weekly = df.groupby(pd.Grouper(key="日期", freq="W")).sum()

4. 时间区间:Period 与 PeriodIndex

Period 表示时间区间(如"2025-01"整个月),而 Timestamp 表示时间点:

# 创建 Period(月)
p = pd.Period("2025-01", freq="M")
print(p)                       # 2025-01
print(p + 1)                   # 2025-02
print(p - 1)                   # 2024-12
print(p.start_time)            # 2025-01-01 00:00:00
print(p.end_time)              # 2025-01-31 23:59:59.999999999

# 季度 / 年
print(pd.Period("2025Q1", freq="Q"))     # 2025Q1
print(pd.Period("2025", freq="Y"))       # 2025

PeriodIndex

# 从日期索引转换
idx = pd.date_range("2025-01-01", periods=6, freq="M")
p_idx = idx.to_period("M")
print(p_idx)
# PeriodIndex(['2025-01', '2025-02', '2025-03', '2025-04', '2025-05', '2025-06'], dtype='period[M]')

# 直接创建
pi = pd.period_range("2025-01", periods=4, freq="M")
print(pi)

# 转换回时间戳(月初/月末)
print(pi.to_timestamp())                 # 每月初
print(pi.to_timestamp(how="end"))        # 每月末

Period 的应用:按周期分组

df = pd.DataFrame({
    "日期": pd.date_range("2025-01-01", periods=100, freq="D"),
    "金额": np.random.randint(100, 1000, 100),
})
# 转成 Period 后按"月/季度"分组
df["月份"] = df["日期"].dt.to_period("M")
df["季度"] = df["日期"].dt.to_period("Q")
print(df.groupby("月份")["金额"].sum())
print(df.groupby("季度")["金额"].mean())

5. 时区与夏令时

# 带时区的时间
ts = pd.Timestamp("2025-03-09 01:30:00", tz="US/Eastern")
print(ts)                    # 2025-03-09 01:30:00-05:00

# 无时区 -> 指定时区
naive = pd.Timestamp("2025-03-09 01:30:00")
aware = naive.tz_localize("US/Eastern")     # 明确本地化为美东
print(aware)

# 转换时区
print(aware.tz_convert("Asia/Shanghai"))

# 处理夏令时(DST)歧义
# 2025-03-09 02:30 在美东不存在(跳时)
try:
    pd.Timestamp("2025-03-09 02:30:00").tz_localize("US/Eastern")
except Exception as e:
    print("DST 歧义:", e)
# 用 ambiguous 参数处理
pd.Timestamp("2025-03-09 02:30:00").tz_localize("US/Eastern", ambiguous="NaT")
# DataFrame 列时区处理
df = pd.DataFrame({"时间": pd.to_datetime(["2025-01-01 08:00", "2025-01-02 09:30"])})
df["上海时间"] = df["时间"].dt.tz_localize("UTC").dt.tz_convert("Asia/Shanghai")
print(df)

# 常见时区
# Asia/Shanghai、UTC、US/Eastern、Europe/London、Asia/Tokyo
坑:tz_localize 是"声明该时间属于哪个时区",tz_convert 是"换算到另一个时区",两者别混。

6. between_time / at_time / truncate

对有时间的索引(尤其日内数据):

idx = pd.date_range("2025-01-01", periods=24 * 4, freq="h")   # 4 天每小时
s = pd.Series(np.random.rand(len(idx)), index=idx)

# 取每天 9:00-17:00
print(s.between_time("09:00", "17:00").head(10))

# 取每天某个时刻
print(s.at_time("12:00"))

# 截断到某个时间范围
print(s.truncate(before="2025-01-02", after="2025-01-03 12:00"))
# 判断是否工作时间
s2 = s.copy()
s2 = s2.to_frame(name="值")
s2["小时"] = s2.index.hour
s2["是否工作时间"] = s2["小时"].between(9, 17)
print(s2.groupby("是否工作时间")["值"].mean())

7. 时间特征工程

机器学习前把时间拆成可用特征(结合第 8 章类型转换):

df = pd.DataFrame({
    "日期": pd.to_datetime(["2025-01-01 08:30", "2025-01-02 18:45", "2025-01-03 12:00"]),
})

df["年"] = df["日期"].dt.year
df["月"] = df["日期"].dt.month
df["日"] = df["日期"].dt.day
df["周几"] = df["日期"].dt.dayofweek          # 0=周一
df["周几名"] = df["日期"].dt.day_name()
df["小时"] = df["日期"].dt.hour
df["分钟"] = df["日期"].dt.minute
df["季度"] = df["日期"].dt.quarter
df["是否周末"] = df["日期"].dt.dayofweek >= 5
df["是否月末"] = df["日期"].dt.is_month_end
df["一年第几天"] = df["日期"].dt.dayofyear
df["ISO周"] = df["日期"].dt.isocalendar().week
df["周序号"] = df["日期"].dt.isocalendar().week.astype(int)

# 循环特征(周期性编码,适合模型)
df["小时_sin"] = np.sin(2 * np.pi * df["小时"] / 24)
df["小时_cos"] = np.cos(2 * np.pi * df["小时"] / 24)
df["月_sin"] = np.sin(2 * np.pi * df["月"] / 12)
df["月_cos"] = np.cos(2 * np.pi * df["月"] / 12)

print(df)

8. 滞后特征与窗口特征综合

构造监督学习用的时间特征(常见于销量预测、风控):

np.random.seed(3)
df = pd.DataFrame({
    "日期": pd.date_range("2025-01-01", periods=60, freq="D"),
    "销量": np.random.randint(50, 200, 60).astype(float),
}).set_index("日期")

# 滞后特征:前 1、7 天
for lag in [1, 7]:
    df[f"销量_lag{lag}"] = df["销量"].shift(lag)

# 窗口统计:3/7 天均值、std、max、min
for w in [3, 7]:
    df[f"均值{w}"] = df["销量"].rolling(w).mean()
    df[f"标准差{w}"] = df["销量"].rolling(w).std()
    df[f"最大{w}"] = df["销量"].rolling(w).max()

# 差分
df["日差分"] = df["销量"].diff()

# 周几特征
df["周几"] = df.index.dayofweek

# 删除含 NaN 的行(前几行没有历史)
df_ml = df.dropna()
print(df_ml.tail())
特征工程后 df.dropna() 会去掉没有历史的前几行,这是训练前的常规操作。

9. 实战:交易日历下的业务指标

结合本章知识做一个"按工作日/节假日统计"的小案例:

import numpy as np
from pandas.tseries.offsets import CustomBusinessDay, BDay
from pandas.tseries.holiday import Holiday, AbstractHolidayCalendar

# 1. 定义节假日日历(示例:元旦+国庆)
class SimpleCN(AbstractHolidayCalendar):
    rules = [
        Holiday("元旦", month=1, day=1),
        Holiday("国庆", month=10, day=1),
    ]
cal = SimpleCN()

# 2. 用业务日历生成"营业日"序列
biz = pd.date_range("2025-01-01", periods=60, freq=CustomBusinessDay(calendar=cal))
print("营业日数量:", len(biz))

# 3. 模拟营业日订单
np.random.seed(7)
df = pd.DataFrame({
    "日期": biz,
    "订单数": np.random.randint(10, 100, len(biz)),
    "金额": np.random.randint(1000, 50000, len(biz)),
}).set_index("日期")

# 4. 每周营业汇总(用 W-MON 对齐)
weekly = df.resample("W-MON").agg({"订单数": "sum", "金额": "sum"})
print(weekly)

# 5. 周末效应检验(把自然日补全后对比)
full = df.resample("D").asfreq().fillna(0)
full["周末"] = full.index.dayofweek >= 5
print(full.groupby("周末")["金额"].mean())

# 6. 营业日 vs 自然日:每单均价
avg_biz = df["金额"].sum() / df["订单数"].sum()
print(f"营业日每单均价: {avg_biz:.2f}")

10. 常见坑与注意事项

| 坑 | 现象 | 解决办法 |

|----|------|----------|

| tz_localize 与 tz_convert 混用 | 时间错 8 小时 | 记住:localize 声明、convert 换算 |

| DST 歧义时间 | 报错/NaT | ambiguous= 参数 |

| asfreq 与 resample 混淆 | 结果不对 | 聚合用 resample,纯频率用 asfreq |

| Period 与 Timestamp 混用 | 运算报错 | 明确对象类型,to_period/to_timestamp 转换 |

| 节假日只按自然日 | 业务指标失真 | 用 CustomBusinessDay + calendar |

| 中国调休未处理 | 周末上班日漏掉 | 使用 chinese_calendar 等库 |


11. 本章小结与练习

小结

  • 业务日:B、BM、W-MON,自定义用 CustomBusinessDay;
  • 节假日:Holiday + AbstractHolidayCalendar 构建日历;
  • 频率转换:asfreq(纯频率)、resample(聚合)、Grouper;
  • Period/PeriodIndex 表示时间区间,to_period("M"/"Q") 做周期分组;
  • 时区:tz_localize → tz_convert,注意 DST;
  • 特征工程:拆分成分 + 循环编码 + 滞后 + 窗口统计。

练习题

1. 生成 2025 年每周五的日期序列。

2. 自定义一个含春节(示例日)的日历,验证节假日被跳过。

3. 把日数据 resample 到月后用 asfreq("D").ffill() 回填。

4. 将一列日期转为 PeriodIndex 并按季度分组求和。

5. 用 between_time 提取每天 9:00-18:00 的数据。


下一篇预告:第 21 章 数据可视化进阶 —— Seaborn 联动、热力图、专业图表与报告风格。
— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 20 章 时间序列高级:工作日历、节假日、时区与频率转换》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。