import numpy as np
import pandas as pd
时间序列的概念在日常生活中十分常见,但对于一个具体的时序事件而言,可以从多个时间对象的角度来描述。例如2020年9月7日周一早上8点整需要到教室上课,这个课会在当天早上10点结束,其中包含了哪些时间概念?
第一,会出现时间戳(Date times)的概念,即’2020-9-7 08:00:00’和’2020-9-7 10:00:00’这两个时间点分别代表了上课和下课的时刻,在pandas
中称为Timestamp
。同时,一系列的时间戳可以组成DatetimeIndex
,而将它放到Series
中后,Series
的类型就变为了datetime64[ns]
,如果有涉及时区则为datetime64[ns, tz]
,其中tz是timezone的简写。
第二,会出现时间差(Time deltas)的概念,即上课需要的时间,两个Timestamp
做差就得到了时间差,pandas中利用Timedelta
来表示。类似的,一系列的时间差就组成了TimedeltaIndex
, 而将它放到Series
中后,Series
的类型就变为了timedelta64[ns]
。
第三,会出现时间段(Time spans)的概念,即在8点到10点这个区间都会持续地在上课,在pandas
利用Period
来表示。类似的,一系列的时间段就组成了PeriodIndex
, 而将它放到Series
中后,Series
的类型就变为了Period
。
第四,会出现日期偏置(Date offsets)的概念,假设你只知道9月的第一个周一早上8点要去上课,但不知道具体的日期,那么就需要一个类型来处理此类需求。再例如,想要知道2020年9月7日后的第30个工作日是哪一天,那么时间差就解决不了你的问题,从而pandas
中的DateOffset
就出现了。同时,pandas
中没有为一列时间偏置专门设计存储类型,理由也很简单,因为需求比较奇怪,一般来说我们只需要对一批时间特征做一个统一的特殊日期偏置。
通过这个简单的例子,就能够容易地总结出官方文档中的这个表格:
概念 | 单元素类型 | 数组类型 | pandas数据类型 |
---|---|---|---|
Date times | Timestamp |
DatetimeIndex |
datetime64[ns] |
Time deltas | Timedelta |
TimedeltaIndex |
timedelta64[ns] |
Time spans | Period |
PeriodIndex |
period[freq] |
Date offsets | DateOffset |
None |
None |
由于时间段对象Period/PeriodIndex
的使用频率并不高,因此将不进行讲解,而只涉及时间戳序列、时间差序列和日期偏置的相关内容。
单个时间戳的生成利用pd.Timestamp
实现,一般而言的常见日期格式都能被成功地转换:
ts = pd.Timestamp('2020/1/1')
ts
ts = pd.Timestamp('2020-1-1 08:10:30')
ts
通过year, month, day, hour, min, second
可以获取具体的数值:
ts.year
ts.month
ts.day
ts.hour
ts.minute
ts.second
在pandas
中,时间戳的最小精度为纳秒ns
,由于使用了64位存储,可以表示的时间范围大约可以如下计算:
T i m e R a n g e = 2 64 1 0 9 × 60 × 60 × 24 × 365 ≈ 585 ( Y e a r s ) \rm Time\,Range = \frac{2^{64}}{10^9\times 60\times 60\times 24\times 365} \approx 585 (Years) TimeRange=109×60×60×24×365264≈585(Years)
通过pd.Timestamp.max
和pd.Timestamp.min
可以获取时间戳表示的范围,可以看到确实表示的区间年数大小正如上述计算结果:
pd.Timestamp.max
pd.Timestamp.min
pd.Timestamp.max.year - pd.Timestamp.min.year
一组时间戳可以组成时间序列,可以用to_datetime
和date_range
来生成。其中,to_datetime
能够把一列时间戳格式的对象转换成为datetime64[ns]
类型的时间序列:
pd.to_datetime(['2020-1-1', '2020-1-3', '2020-1-6'])
df = pd.read_csv('../data/learn_pandas.csv')
s = pd.to_datetime(df.Test_Date)
s.head()
在极少数情况,时间戳的格式不满足转换时,可以强制使用format
进行匹配:
temp = pd.to_datetime(['2020\\1\\1','2020\\1\\3'],format='%Y\\%m\\%d')
temp
注意上面由于传入的是列表,而非pandas
内部的Series
,因此返回的是DatetimeIndex
,如果想要转为datetime64[ns]
的序列,需要显式用Series
转化:
pd.Series(temp).head()
另外,还存在一种把表的多列时间属性拼接转为时间序列的to_datetime
操作,此时的列名必须和以下给定的时间关键词列名一致:
df_date_cols = pd.DataFrame({'year': [2020, 2020],
'month': [1, 1],
'day': [1, 2],
'hour': [10, 20],
'minute': [30, 50],
'second': [20, 40]})
pd.to_datetime(df_date_cols)
date_range
是一种生成连续间隔时间的一种方法,其重要的参数为start, end, freq, periods
,它们分别表示开始时间,结束时间,时间间隔,时间戳个数。其中,四个中的三个参数决定了,那么剩下的一个就随之确定了。这里要注意,开始或结束日期如果作为端点则它会被包含:
pd.date_range('2020-1-1','2020-1-21', freq='10D') # 包含
pd.date_range('2020-1-1','2020-2-28', freq='10D')
pd.date_range('2020-1-1', '2020-2-28', periods=6) # 由于结束日期无法取到,freq不为10天
这里的freq
参数与DateOffset
对象紧密相关,将在第四节介绍其具体的用法。
【练一练】
Timestamp
上定义了一个value
属性,其返回的整数值代表了从1970年1月1日零点到给定时间戳相差的纳秒数,请利用这个属性构造一个随机生成给定日期区间内日期序列的函数。
import random
def range_date(start,end):
start_date = pd.Timestamp(start).value
end_date = pd.Timestamp(end).value
ran_date = random.randint(start_date,end_date)
return pd.Timestamp(ran_date)
range_date('2020-1-1','2020-1-28')
最后,要介绍一种改变序列采样频率的方法asfreq
,它能够根据给定的freq
对序列进行类似于reindex
的操作:
s = pd.Series(np.random.rand(5), index=pd.to_datetime(['2020-1-%d'%i for i in range(1,10,2)]))
s.head()
s.asfreq('D').head()
s.asfreq('12H').head()
【NOTE】
前面提到了datetime64[ns]
本质上可以理解为一个大整数,对于一个该类型的序列,可以使用max, min, mean
,来取得最大时间戳、最小时间戳和“平均”时间戳。
如同category, string
的序列上定义了cat, str
来完成分类数据和文本数据的操作,在时序类型的序列上定义了dt
对象来完成许多时间序列的相关操作。这里对于datetime64[ns]
类型而言,可以大致分为三类操作:取出时间相关的属性、判断时间戳是否满足条件、取整操作。
第一类操作的常用属性包括:date, time, year, month, day, hour, minute, second, microsecond, nanosecond, dayofweek, dayofyear, weekofyear, daysinmonth, quarter
,其中daysinmonth, quarter
分别表示该月一共有几天和季度。
s = pd.Series(pd.date_range('2020-1-1','2020-1-3', freq='D'))
s.dt.date
s.dt.time
s.dt.day
s.dt.daysinmonth
在这些属性中,经常使用的是dayofweek
,它返回了周中的星期情况,周一为0、周二为1,以此类推:
s.dt.dayofweek
此外,可以通过month_name, day_name
返回英文的月名和星期名,注意它们是方法而不是属性:
s.dt.month_name()
s.dt.day_name()
第二类判断操作主要用于测试是否为月/季/年的第一天或者最后一天:
s.dt.is_year_start # 还可选 is_quarter/month_start
s.dt.is_year_end # 还可选 is_quarter/month_end
第三类的取整操作包含round, ceil, floor
,它们的公共参数为freq
,常用的包括H, min, S
(小时、分钟、秒),所有可选的freq
可参考此处。
s = pd.Series(pd.date_range('2020-1-1 20:35:00', '2020-1-1 22:35:00', freq='45min'))
s
s.dt.round('1H')
s.dt.ceil('1H')
s.dt.floor('1H')
一般而言,时间戳序列作为索引使用。如果想要选出某个子时间戳序列,第一类方法是利用dt
对象和布尔条件联合使用,另一种方式是利用切片,后者常用于连续时间戳。下面,举一些例子说明:
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
s.head()
s[(idx.is_month_start|idx.is_month_end).values].head()
s[idx.dayofweek.isin([5,6]).values].head()
s['2020-01-01']
s['20200101'] # 自动转换标准格式
s['2020-07'].head()
s['2020-05':'2020-7-15'].head()
s['2020-05':'2020-7-15'].tail()
正如在第一节中所说,时间差可以理解为两个时间戳的差,这里也可以通过pd.Timedelta
来构造:
pd.Timestamp('20200102 08:00:00')-pd.Timestamp('20200101 07:35:00')
pd.Timedelta(days=1, minutes=25) # 需要注意加s
pd.Timedelta('1 days 25 minutes') # 字符串生成
生成时间差序列的主要方式是pd.to_timedelta
,其类型为timedelta64[ns]
:
s = pd.to_timedelta(df.Time_Record)
s.head()
与date_range
一样,时间差序列也可以用timedelta_range
来生成,它们两者具有一致的参数:
pd.timedelta_range('0s', '1000s', freq='6min')
pd.timedelta_range('0s', '1000s', periods=3)
对于Timedelta
序列,同样也定义了dt
对象,上面主要定义了的属性包括days, seconds, mircroseconds, nanoseconds
,它们分别返回了对应的时间差特征。需要注意的是,这里的seconds
不是指单纯的秒,而是对天数取余后剩余的秒数:
s.dt.seconds.head()
如果不想对天数取余而直接对应秒数,可以使用total_seconds
s.dt.total_seconds().head()
pd.to_timedelta(df.Time_Record).dt.round('min').head()
时间差支持的常用运算有三类:与标量的乘法运算、与时间戳的加减法运算、与时间差的加减法与除法运算:
td1 = pd.Timedelta(days=1)
td2 = pd.Timedelta(days=3)
ts = pd.Timestamp('20200101')
td1 * 2
td2 - td1
ts + td1
ts - td1
td1 = pd.timedelta_range(start='1 days', periods=5)
td2 = pd.timedelta_range(start='12 hours', freq='2H', periods=5)
ts = pd.date_range('20200101', '20200105')
td1 * 5
td1 * pd.Series(list(range(5))) # 逐个相乘
td1 - td2
td1 + pd.Timestamp('20200101')
td1 + ts # 逐个相加
日期偏置是一种和日历相关的特殊时间差,例如回到第一节中的两个问题:如何求2020年9月第一个周一的日期,以及如何求2020年9月7日后的第30个工作日是哪一天。
pd.Timestamp('20200831') + pd.offsets.WeekOfMonth(week=0,weekday=0)
pd.Timestamp('20200907') + pd.offsets.BDay(30)
从上面的例子中可以看到,Offset
对象在pd.offsets
中被定义。当使用+
时获取离其最近的下一个日期,当使用-
时获取离其最近的上一个日期:
pd.Timestamp('20200831') - pd.offsets.WeekOfMonth(week=0,weekday=0)
pd.Timestamp('20200907') - pd.offsets.BDay(30)
pd.Timestamp('20200907') + pd.offsets.MonthEnd()
常用的日期偏置如下可以查阅这里的文档描述。在文档罗列的Offset
中,需要介绍一个特殊的Offset
对象CDay
,其中的holidays, weekmask
参数能够分别对自定义的日期和星期进行过滤,前者传入了需要过滤的日期列表,后者传入的是三个字母的星期缩写构成的星期字符串,其作用是只保留字符串中出现的星期:
my_filter = pd.offsets.CDay(n=1,weekmask='Wed Fri',holidays=['20200109'])
dr = pd.date_range('20200108', '20200111')
dr.to_series().dt.dayofweek
[i + my_filter for i in dr]
上面的例子中,n
表示增加一天CDay
,dr
中的第一天为20200108
,但由于下一天20200109
被排除了,并且20200110
是合法的周五,因此转为20200110
,其他后面的日期处理类似。
【CAUTION】不要使用部分Offset
在当前版本下由于一些 bug
,不要使用 Day
级别以下的 Offset
对象,比如 Hour, Second
等,请使用对应的 Timedelta
对象来代替。
前面提到了关于date_range
的freq
取值可用Offset
对象,同时在pandas
中几乎每一个Offset
对象绑定了日期偏置字符串(frequencies strings/offset aliases
),可以指定Offset
对应的字符串来替代使用。下面举一些常见的例子。
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') # 每月第一个周一
pd.date_range('20200101','20200331', freq=pd.offsets.MonthBegin())
pd.date_range('20200101','20200331', freq=pd.offsets.MonthEnd())
pd.date_range('20200101','20200110', freq=pd.offsets.BDay())
pd.date_range('20200101','20200201', freq=pd.offsets.CDay(weekmask='Mon'))
pd.date_range('20200101','20200201', freq=pd.offsets.WeekOfMonth(week=0,weekday=0))
【CAUTION】关于时区问题的说明
各类时间对象的开发,除了使用python
内置的datetime
模块,pandas
还利用了dateutil
模块,很大一部分是为了处理时区问题。众所周知,我国是没有夏令时调整时间一说的,但有些国家会有这种做法,导致了相对而言一天里可能会有23/24/25个小时,也就是relativedelta
,这使得Offset
对象和Timedelta
对象有了对同一问题处理产生不同结果的现象,其中的规则也较为复杂,官方文档的写法存在部分描述错误,并且难以对描述做出统一修正,因为牵涉到了Offset
相关的很多组件。因此,本教程完全不考虑时区处理,如果对时区处理的时间偏置有兴趣了解讨论,可以联系我或者参见这里的讨论。
所谓时序的滑窗函数,即把滑动窗口用freq
关键词代替,下面给出一个具体的应用案例:在股票市场中有一个指标为BOLL
指标,它由中轨线、上轨线、下轨线这三根线构成,具体的计算方法分别是N
日均值线、N
日均值加两倍N
日标准差线、N
日均值减两倍N
日标准差线。利用rolling
对象计算N=30
的BOLL
指标可以如下写出:
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)
s.head()
r = s.rolling('30D')
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)
对于shift
函数而言,作用在datetime64
为索引的序列上时,可以指定freq
单位进行滑动:
s.shift(freq='50D').head()
另外,datetime64[ns]
的序列进行diff
后就能够得到timedelta64[ns]
的序列,这能够使用户方便地观察有序时间序列的间隔:
my_series = pd.Series(s.index)
my_series.head()
my_series.diff(1).head()
重采样对象resample
和第四章中分组对象groupby
的用法类似,前者是针对时间序列的分组计算而设计的分组对象。
例如,对上面的序列计算每10天的均值:
s.resample('10D').mean().head()
同时,如果没有内置定义的处理函数,可以通过apply
方法自定义:
s.resample('10D').apply(lambda x:x.max()-x.min()).head() # 极差
在resample
中要特别注意组边界值的处理情况,默认情况下起始值的计算方法是从最小值时间戳对应日期的午夜00:00:00
开始增加freq
,直到不超过该最小时间戳的最大时间戳,由此对应的时间戳为起始值,然后每次累加freq
参数作为分割结点进行分组,区间情况为左闭右开。下面构造一个不均匀的例子:
idx = pd.date_range('20200101 8:26:35', '20200101 9:31:58', freq='77s')
data = np.random.randint(-1,2,len(idx)).cumsum()
s = pd.Series(data,index=idx)
s.head()
下面对应的第一个组起始值为08:24:00
,其是从当天0点增加72个freq=7 min
得到的,如果再增加一个freq
则超出了序列的最小时间戳08:26:35
:
s.resample('7min').mean().head()
有时候,用户希望从序列的最小时间戳开始依次增加freq
进行分组,此时可以指定origin
参数为start
:
s.resample('7min', origin='start').mean().head()
在返回值中,要注意索引一般是取组的第一个时间戳,但M, A, Q, BM, BA, BQ, W
这七个是取对应区间的最后一个时间戳:
s = pd.Series(np.random.randint(2,size=366), index=pd.date_range('2020-01-01', '2020-12-31'))
s.resample('M').mean().head()
s.resample('MS').mean().head() # 结果一样,但索引不同
现有一份关于太阳辐射的数据集:
df = pd.read_csv('../data/solar.csv', usecols=['Data','Time','Radiation','Temperature'])
df.head(3)
Datetime, Time
合并为一个时间列Datetime
,同时把它作为索引后排序。df = pd.read_csv('../data/solar.csv', usecols=['Data','Time','Radiation','Temperature'])
solar_date = df.Data.str.extract('([/|\w]+\s).+')[0]
df['Data'] = pd.to_datetime(solar_date + df.Time)
df = df.drop(columns='Time').rename(columns={'Data':'Datetime'}).set_index('Datetime').sort_index()
df.head(3)
s = df.index.to_series().reset_index(drop=True).diff().dt.total_seconds()
max_3 = s.nlargest(3).index
df.index[max_3.union(max_3-1)]
bins=50
。res = s.mask((s>s.quantile(0.99))|(s<s.quantile(0.01)))
_ = plt.hist(res, bins=50)
res = df.Radiation.rolling('6H').corr(df.Temperature)
res.tail(3)
res = df.Temperature.resample('6H', origin='03:00:00').mean()
res.head(3)
my_dt = df.index.shift(freq='-6H')
int_loc = [df.index.get_loc(i, method='nearest') for i in my_dt]
res = df.Radiation.iloc[int_loc]
res.tail(3)
现有一份2019年每日水果销量记录表:
df = pd.read_csv('../data/fruit.csv')
df.head(3)
df = pd.read_csv('../data/fruit.csv')
df.Date = pd.to_datetime(df.Date)
df_grape = df.query("Fruit == 'Grape'")
res = df_grape.groupby([np.where(df_grape.Date.dt.day<=15,'First', 'Second'),
df_grape.Date.dt.month])['Sale'].mean().to_frame().unstack(0).droplevel(0,axis=1)
res = (res.First/res.Second).rename_axis('Month')
res.head()
df[df.Date.dt.is_month_end].query("Fruit == 'Pear'").groupby('Date').Sale.sum().head()
df[df.Date.isin(pd.date_range('20190101', '20191231',freq='BM'))].query("Fruit == 'Pear'").groupby('Date').Sale.mean().head()
target_dt = df.drop_duplicates().groupby(df.Date.drop_duplicates().dt.month)['Date'].nlargest(5).reset_index(drop=True)
res = df.set_index('Date').loc[target_dt].reset_index().query("Fruit == 'Apple'")
res = res.groupby(res.Date.dt.month)['Sale'].mean().rename_axis('Month')
res.head()
2. 按月计算周一至周日各品种水果的平均记录条数,行索引外层为水果名称,内层为月份,列索引为星期。
month_order = ['January','February','March','April','May','June','July','August','September','October','November','December']
week_order = ['Mon','Tue','Wed','Thu','Fri','Sat','Sum']
group1 = df.Date.dt.month_name().astype('category').cat.reorder_categories(month_order, ordered=True)
group2 = df.Fruit
group3 = df.Date.dt.dayofweek.replace(dict(zip(range(7),week_order))).astype('category').cat.reorder_categories(week_order, ordered=True)
res = df.groupby([group1, group2,group3])['Sale'].count().to_frame().unstack(0).droplevel(0,axis=1)
res.head()
3. 按天计算向前10个工作日窗口的苹果销量均值序列,非工作日的值用上一个工作日的结果填充。
df_apple = df[(df.Fruit=='Apple')&(~df.Date.dt.dayofweek.isin([5,6]))]
s = pd.Series(df_apple.Sale.values,index=df_apple.Date).groupby('Date').sum()
res = s.rolling('10D').mean().reindex(pd.date_range('20190101','20191231')).fillna(method='ffill')
res.head()