Pandas时序数据

时间序列的概念在日常生活中十分常见,但对于一个具体的时序事件而言,可以从多个时间对象的角度来描述。例如 “2020 年 9 月 7 日周一早上 8 点整需要到教室上课,这个课会在当天早上 10 点结束”,其中包含了如下一些时序概念:

  • 时间戳: 即 ’2020-9-7 08\:00:00’ 和 ’2020-9-7 10\:00:00’这两个时间点分别代表了上课和下课的时刻,在 Pandas 中称为 Timestamp 。多个时间戳可以构成一个 DatetimeIndex 对象,该对象的 dtype 为 datetime64[ns] ,如果涉及时区则为 datetime64[ns, tz] , tz 是 timezone 的简写。
  • 时间差: 即上课需要的时间,通过两个 Timestamp 做差就可以计算出时间差,Pandas 中用 Timedelta 来表示。多个时间差可以构成一个 TimedeltaIndex 对象,该对象的 dtype 为 timedelta64[ns]
  • 时间段: 即在 8 点到 10 点这个区间都会持续地在上课,在 Pandas 利用 Period 来表示。一系列的时间段组成了 PeriodIndex ,而将它放到 Series 中后,该 Series 的类型就变为了 Period
  • 日期偏置: 想要知道 2020 年 9 月 7 日后的第 30 个工作日是哪一天, Pandas 提供了多种 Offset 对象来完成相应的计算。但是,Pandas 并没有为一系列的时间偏置专门设计存储类型。
1
import pandas as pd

由于时间段对象 Period/PeriodIndex 的使用频率并不高,因此将不进行讲解,我们只重点介绍时间戳序列、时间差序列和日期偏置的相关内容。

时间戳

常见的时间格式串可以直接传入 pd.Timestamp( ) 中转换成时间戳对象,该对象包含了 year, month, day, hour, minute, second 等属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
print(pd.Timestamp('2020/1/1'))
''''
2020-01-01 00:00:00
'''

ts = pd.Timestamp('2020-1-1 08:10:30')
print(ts)
'''
2020-01-01 08:10:30
'''

print(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second)
'''
2020 1 1 8 10 30
'''

在 Pandas 中,时间戳的最小精度为纳秒 ns,由于时间戳本身是一个纳秒数值,所以可以通过 pd.Timestamp.max 和 pd.Timestamp.min 获取时间戳的表示范围。

Datatime序列

多个时间戳对象可以组成 DatatimeIndex,通过 to_datetime( ) 和 date_range( ) 来生成。其中,to_datetime( ) 的输入可以是时间格式串或时间戳对象。

1
2
3
4
5
6
7
8
9
10
11
12
# 由一系列的Timstamp对象生成DatetimeIndex对象
ts_lst = [pd.Timestamp('2020/1/1'), pd.Timestamp('2020/1/3'), pd.Timestamp('2020/1/6')]
print(pd.to_datetime(ts_lst))
'''
DatetimeIndex(['2020-01-01', '2020-01-03', '2020-01-06'], dtype='datetime64[ns]', freq=None)
'''

# 直接由时间格式串生成DatetimeIndex对象,时间格式不满足转换时,可以强制使用format进行匹配
temp = pd.to_datetime(['2020\\1\\1','2020\\1\\3'],format='%Y\\%m\\%d')
'''
DatetimeIndex(['2020-01-01', '2020-01-03'], dtype='datetime64[ns]', freq=None)
'''

date_range() 是一种生成连续间隔时间的一种方法,其重要的参数为 start, end, freq, periods ,它们分别表示开始时间,结束时间,时间间隔,时间戳个数。其中,四个中的三个参数决定了,那么剩下的一个就随之确定了。这里要注意,开始或结束日期如果作为端点则它会被包含。

1
2
3
4
5
6
7
8
9
10
11
12
print(pd.date_range('2020-1-1','2020-1-21', freq='10D'))
'''
DatetimeIndex(['2020-01-01', '2020-01-11', '2020-01-21'], dtype='datetime64[ns]', freq='10D')
'''

print(pd.date_range('2020-1-1', '2020-2-28', periods=6))
'''
DatetimeIndex(['2020-01-01 00:00:00', '2020-01-12 14:24:00',
'2020-01-24 04:48:00', '2020-02-04 19:12:00',
'2020-02-16 09:36:00', '2020-02-28 00:00:00'],
dtype='datetime64[ns]', freq=None)
'''

另外,DatatimeIndex 对象经过 Series 转换后就形成了 “Datetime 序列”

1
2
3
4
5
6
7
# 上文生成的DatetimeIndex对象转换为Series
print(pd.Series(temp).head())
'''
0 2020-01-01
1 2020-01-03
dtype: datetime64[ns]
'''

如果传入的不是多个时间戳的列表,而是时间戳或时间格式串组成的 Series,那么 to_datetime( ) 函数将直接返回一个 Datetime 序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
df = pd.read_csv('data/learn_pandas.csv')
ds = pd.to_datetime(df.Time_Record)
print(ds.head())
'''
0 2021-07-31 00:04:34
1 2021-07-31 00:04:20
2 2021-07-31 00:05:22
3 2021-07-31 00:04:08
4 2021-07-31 00:05:22
Name: Time_Record, dtype: datetime64[ns]
'''

# 把表的多列时间属性拼接转为时间序列,此时的列名必须和以下给定的时间关键词列名一致
df_date_cols = pd.DataFrame({'year': [2020, 2020], 'month': [1, 1],
'day': [1, 2], 'hour': [10, 20],
'minute': [30, 50], 'second': [20, 40]})
print(pd.to_datetime(df_date_cols))
'''
0 2020-01-01 10:30:20
1 2020-01-02 20:50:40
dtype: datetime64[ns]
'''

Pandas 对于 Datetime 序列定义了 dt 对象来完成许多时间序列的相关操作。针对 datetime64[ns] 类型而言,可以大致分为三类操作:取出时间相关的属性、判断时间戳是否满足条件、取整操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
s = pd.Series(pd.date_range('2020-1-1','2020-1-3', freq='D'))
print(s.dt.month_name())
'''
0 January
1 January
2 January
dtype: object
'''

print(s.dt.is_year_start)
'''
0 True
1 False
2 False
dtype: bool
'''

s = pd.Series(pd.date_range('2020-1-1 20:35:00', '2020-1-1 22:35:00', freq='45min'))
print(s.dt.round('1H'))
'''
0 2020-01-01 21:00:00
1 2020-01-01 21:00:00
2 2020-01-01 22:00:00
dtype: datetime64[ns]
'''

第一类操作的常用属性包括:date, time, year, month, day, hour, minute, second, microsecond, nanosecond, dayofweek, dayofyear, weekofyear, daysinmonth, quarter ,其中,dayofweek 返回周中的星期情况,周一为0、周二为1,以此类推; daysinmonth, quarter 分别表示月中的第几天和季度。另外还有类属性的方法,month_name(), day_name() 返回英文的月名和星期名。第二类判断操作主要用于测试是否为月/季/年的第一天或者最后一天。第三类的取整操作包含 round, ceil, floor ,它们的公共参数为 freq ,常用的包括 H, min, S (小时、分钟、秒)。

索引与切片

Datetime 序列常作为索引使用,如果想要选出某个子时间戳序列,第一类方法是利用 dt 对象和布尔
条件联合使用,另一种方式是利用切片,后者常用于连续时间戳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import numpy as np
# 时间戳的切片与索引
s = pd.Series(np.random.randint(2,size=366),
index=pd.date_range('2020-01-01','2020-12-31'))
idx = pd.Series(s.index).dt
print(s.head())
'''
2020-01-01 1
2020-01-02 1
2020-01-03 1
2020-01-04 1
2020-01-05 1
Freq: D, dtype: int32
'''

# 每月的第一天或者最后一天
print(s[(idx.is_month_start|idx.is_month_end).values].head())
# 双休日
print(s[idx.dayofweek.isin([5,6]).values].head())
'''
2020-01-04 1
2020-01-05 1
2020-01-11 1
2020-01-12 0
2020-01-18 0
dtype: int32
'''

# 取出七月
print(s['2020-07'].head())
# 取出5月初至7月15日
print(s['2020-05':'2020-7-15'].head())

最后,我们介绍一种改变序列采样频率的方法 asfreq():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
s = pd.Series(np.random.rand(5),
index=pd.to_datetime(['2020-1-%d'%i for i in range(1,10,2)]))
print(s.head())
'''
2020-01-01 0.930396
2020-01-03 0.697312
2020-01-05 0.852363
2020-01-07 0.215163
2020-01-09 0.993382
dtype: float64
'''

print(s.asfreq('D').head())
'''
2020-01-01 0.930396
2020-01-02 NaN
2020-01-03 0.697312
2020-01-04 NaN
2020-01-05 0.852363
Freq: D, dtype: float64
'''

可以看到,我们将时间序列的索引转换为了指定的频率,函数也提供了设置填充方法,来填充缺失值,返回一个符合指定频率的新索引的原始数据。

时间差

时间差可以理解为两个时间戳的差,可以显式地通过 pd.Timedelta 来构造。

1
2
3
4
5
6
7
8
9
10
temp = pd.Timestamp('20200102 08:00:00')-pd.Timestamp('20200101 07:35:00')
print(temp)
'''
1 days 00:25:00
'''

print(pd.Timedelta(days=1, minutes=25))
'''
1 days 00:25:00
'''

与时间戳类似,多个时间差对象可以组成 TimeDeltaIndex,通过 to_timedelta( ) 和 timedelta_range( ) 来生成。TimeDeltaIndex 对象经过 Series 转换或者直接传给 to_timedelta( ) 时间差组成的 Series,就可以得到 “TimeDelta 序列”。Pandas 为 TimeDelta 序列同样定义了 dt 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
print(pd.to_timedelta([temp]))
'''
TimedeltaIndex(['1 days 00:25:00'], dtype='timedelta64[ns]', freq=None)
'''

s = pd.to_timedelta(df.Time_Record)
print(s.head())
'''
0 0 days 00:04:34
1 0 days 00:04:20
2 0 days 00:05:22
3 0 days 00:04:08
4 0 days 00:05:22
Name: Time_Record, dtype: timedelta64[ns]
'''

print(s.dt.seconds) # seconds不是指单纯的秒,而是对天数取余后剩余的秒数
'''
0 274
1 260
2 322
3 248
4 322
Name: Time_Record, dtype: int64
'''

print(pd.timedelta_range('0s', '1000s', freq='6min'))
'''
TimedeltaIndex(['0 days 00:00:00', '0 days 00:06:00', '0 days 00:12:00'], dtype='timedelta64[ns]', freq='6T')
'''

Timedelta 的运算

时间差支持的常用运算有三类:与标量的乘法运算、与时间戳的加减法运算、与时间差的加减法与除法运算:

1
2
3
4
5
6
7
8
td1 = pd.Timedelta(days=1)
td2 = pd.Timedelta(days=3)
ts = pd.Timestamp('20200101')

print(td1 * 2) #>>> 2 days 00:00:00
print(td2 - td1) #>>> 2 days 00:00:00
print(ts + td1) #>>> 2020-01-02 00:00:00
print(ts - td1) #>>> 2019-12-31 00:00:00

这些运算都可以移植到时间差序列上。

日期偏置

日期偏置是一种和日历相关的特殊时间差,例如回到第一节中的两个问题:如何求 2020 年 9 月第一个周一的日期,以及如何求 2020 年 9 月 7 日后的第 30 个工作日是哪一天:

1
2
3
4
5
6
7
8
9
print(pd.Timestamp('20200831') + pd.offsets.WeekOfMonth(week=0,weekday=0))
'''
2020-09-07 00:00:00
'''

print(pd.Timestamp('20200907') - pd.offsets.BDay(30))
'''
2020-10-19 00:00:00
'''

从上面的实现可以看到,Pandas 为我们提供了Offset 对象来计算日期偏置,它们在 pd.offsets 中被定义。当使用 + 运算时获取离其最近的下一个日期,当使用 - 时获取离其最近的上一个日期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
print(pd.Timestamp("2018-06-25") + pd.offsets.DateOffset(weeks=2, days=5))
'''
2018-07-14 00:00:00
'''

print(pd.Timestamp('20200907') + pd.offsets.MonthEnd())
'''
2020-09-30 00:00:00
'''

my_filter = pd.offsets.CDay(n=1, weekmask='Wed Fri', holidays=['20200109'])
dr = pd.date_range('20200108', '20200111')
print([i + my_filter for i in dr])
'''
[Timestamp('2020-01-10 00:00:00'), # 20200108开始后的第一个周三或周五,且不是1月9号
Timestamp('2020-01-10 00:00:00'),
Timestamp('2020-01-15 00:00:00'),
Timestamp('2020-01-15 00:00:00')] # 20200111开始后的第一个周三或周五
'''

上面的几个 offset 对象中需要特别介绍的是 CDay,其参数 n 表示 offset 增量是一天, holidays, weekmask 参数能够分别对自定义的日期和星期进行过滤,前者传入了需要过滤的日期列表,后者传入的是三个字母的星期缩写构成的星期字符串,其作用是只保留字符串中出现的星期。

偏置字符串

使用 date_range 生成时间序列的 freq 参数取值可以是 Offset 对象,同时在 Pandas 中几乎每一个 Offset 对象绑定了日期偏置字符串来表示特定的时间偏移量。

1
2
3
4
5
pd.date_range('20200101','20200331', freq='MS') # 月初
pd.date_range('20200101','20200331', freq='M') # 月末
pd.date_range('20200101','20200110', freq='B') # 工作日
pd.date_range('20200101','20200201', freq='W-MON') # 周一
pd.date_range('20200101','20200201', freq='WOM-1MON') # 每月第一个周一

滑动窗口

回忆我们在《Pandas基础知识》一节中介绍的“滑窗函数”(rolling/shift/diff/pct_change),如果适用到时序数据上,可以把滑动窗口用 freq 关键词代替之:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import matplotlib.pyplot as plt

# 模拟绘制股票市场中的布林指标
idx = pd.date_range('20200101', '20201231', freq='B')
np.random.seed(2020)
data = np.random.randint(-1,2,len(idx)).cumsum() # 随机游动构造模拟序列
s = pd.Series(data,index=idx)

r = s.rolling('30D') # window参数兼容使用freq关键词

plt.plot(s)
plt.title('BOLL LINES')
plt.plot(r.mean())
plt.plot(r.mean()+r.std()*2)
plt.plot(r.mean()-r.std()*2)
plt.show()

s.shift(freq='50D').head() # 指定freq参数
'''
2020-02-20 -1
2020-02-21 -2
2020-02-22 -1
2020-02-25 -1
2020-02-26 -2
dtype: int32
'''

重采样

重采样对象 resample 可以看做是针对时间序列的分组计算而设计的分组对象(Groupby对象)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
print(s.resample('10D').mean().head())
'''
2020-01-01 -2.000000
2020-01-11 -3.166667
2020-01-21 -3.625000
2020-01-31 -4.000000
2020-02-10 -0.375000
Freq: 10D, dtype: float64
'''

print(s.resample('10D').apply(lambda x:x.max()-x.min()).head()) # 极差
'''
2020-01-01 3
2020-01-11 4
2020-01-21 4
2020-01-31 2
2020-02-10 4
Freq: 10D, dtype: int32
'''

在 resample 中要特别注意组边界值的处理情况,默认情况下起始值的计算方法是从最小值时间戳对应日期的午夜 00\:00:00 开始增加 freq ,直到不超过该最小时间戳的最大时间戳,由此对应的时间戳为起始值,然后每次累加 freq 参数作为分割结点进行分组,区间情况为左闭右开。