第 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 联动、热力图、专业图表与报告风格。
文章回复
0 条公开回复