与公众号同步更新,详细内容及相关ipynb文件在公众号中,公众号:AI入门小白
在多个时间点观察或测量到的任何事物都可以形成⼀段时间序列。很多时间序列是固定频率的,也就是说,数据点是根据某种规律定期出现的(⽐如每15秒、每5分钟、每⽉出现⼀次)。时间序列也可以是不定期的,没有固定的时间单位或单位之间的偏移量。时间序列数据的意义取决于具体的应⽤场景,主要有以下⼏种:
本章主要讲解前3种时间序列。许多技术都可⽤于处理实验型时间序列,其索引可能是⼀个整数或浮点数(表示从实验开始算起已经过去的时间)。最简单也最常⻅的时间序列都是⽤时间戳进⾏索引的。
提示:pandas也⽀持基于timedeltas的指数,它可以有效代表实验或经过的时间。这本书不涉及timedelta指数,但你可以学习pandas的⽂档(http://pandas.pydata.org/ )。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
Python标准库包含⽤于⽇期(date)和时间(time)数据的数据类型,⽽且还有⽇历⽅⾯的功能。我们主要会⽤到datetime、time以及calendar模块。datetime.datetime(也可以简写为datetime)是⽤得最多的数据类型:
from datetime import datetime
now = datetime.now()
now
now.year, now.month, now.day
datetime以毫秒形式存储⽇期和时间。timedelta表示两个datetime对象之间的时间差:
delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15)
delta
delta.days
delta.seconds
可以给datetime对象加上(或减去)⼀个或多个timedelta,这样会产⽣⼀个新对象:
from datetime import timedelta
start = datetime(2011, 1, 7)
start + timedelta(12)
start - 2 * timedelta(12)
利⽤str或strftime⽅法(传⼊⼀个格式化字符串),datetime对象和pandas的Timestamp对象(稍后就会介绍)可以被格式化为字符串:
stamp = datetime(2011, 1, 3)
str(stamp)
stamp.strftime('%Y-%m-%d')
表11-2 datetime格式定义(兼容ISO C89)
datetime.strptime可以⽤这些格式化编码将字符串转换为⽇期:
value = '2011-01-03'
datetime.strptime(value, '%Y-%m-%d')
datestrs = ['7/6/2011', '8/6/2011']
[datetime.strptime(x, '%m/%d/%Y') for x in datestrs]
datetime.strptime是通过已知格式进⾏⽇期解析的最佳⽅式。但是每次都要编写格式定义是很麻烦的事情,尤其是对于⼀些常⻅的⽇期格式。这种情况下,你可以⽤dateutil这个第三⽅包中的parser.parse⽅法(pandas中已经⾃动安装好了):
from dateutil.parser import parse
parse('2011-01-03')
dateutil可以解析⼏乎所有⼈类能够理解的⽇期表示形式:
parse('Jan 31, 1997 10:45 PM')
在国际通⽤的格式中,⽇出现在⽉的前⾯很普遍,传⼊dayfirst=True
即可解决这个问题:
parse('6/12/2011', dayfirst=True)
pandas通常是⽤于处理成组⽇期的,不管这些⽇期是DataFrame的轴索引还是列。to_datetime⽅法可以解析多种不同的⽇期表示形式。对标准⽇期格式(如ISO8601)的解析⾮常快:
datestrs = ['2011-07-06 12:00:00', '2011-08-06 00:00:00']
pd.to_datetime(datestrs)
它还可以处理缺失值(None、空字符串等):
idx = pd.to_datetime(datestrs + [None])
idx
pd.isnull(idx)
NaT(Not a Time)是pandas中时间戳数据的null值。
注意:dateutil.parser是⼀个实⽤但不完美的⼯具。⽐如说,它会把⼀些原本不是⽇期的字符串认作是⽇期(⽐如"42"会被解析为2042年的今天)。
datetime对象还有⼀些特定于当前环境(位于不同国家或使⽤不同语⾔的系统)的格式化选项。例如,德语或法语系统所⽤的⽉份简写就与英语系统所⽤的不同。表11-3进⾏了总结。
pandas最基本的时间序列类型就是以时间戳(通常以Python字符串或datatime对象表示)为索引的Series:
from datetime import datetime
dates = [datetime(2011, 1, 2), datetime(2011, 1, 5),
datetime(2011, 1, 7), datetime(2011, 1, 8),
datetime(2011, 1, 10), datetime(2011, 1, 12)]
ts = pd.Series(np.random.randn(6), index=dates)
ts
这些datetime对象实际上是被放在⼀个DatetimeIndex中的:
ts.index
跟其他Series⼀样,不同索引的时间序列之间的算术运算会⾃动按⽇期对⻬:
ts + ts[::2]
pandas⽤NumPy的datetime64数据类型以纳秒形式存储时间戳:
ts.index.dtype
DatetimeIndex中的各个标量值是pandas的Timestamp对象:
stamp = ts.index[0]
stamp
只要有需要,TimeStamp可以随时⾃动转换为datetime对象。此外,它还可以存储频率信息(如果有的话),且知道如何执⾏时区转换以及其他操作。稍后将对此进⾏详细讲解。
当你根据标签索引选取数据时,时间序列和其它的pandas.Series
很像:
stamp = ts.index[2]
ts[stamp]
ts['1/10/2011']
ts['20110110']
对于较⻓的时间序列,只需传⼊“年”或“年⽉”即可轻松选取数据的切⽚:
longer_ts = pd.Series(np.random.randn(1000),
index=pd.date_range('1/1/2000', periods=1000)) # periods:要生成的周期数。
longer_ts
longer_ts['2001']
这⾥,字符串“2001”被解释成年,并根据它选取时间区间。指定⽉也同样奏效:
longer_ts['2001-05']
ts[datetime(2011, 1, 7):]
由于⼤部分时间序列数据都是按照时间先后排序的,因此你也可以⽤不存在于该时间序列中的时间戳对其进⾏切⽚(即范围查询):
ts
ts['1/6/2011':'1/11/2011']
跟之前⼀样,你可以传⼊字符串⽇期、datetime或Timestamp。
注意,这样切⽚所产⽣的是源时间序列的视图,跟NumPy数组的切⽚运算是⼀样的。
这意味着,没有数据被复制,对切⽚进⾏修改会反映到原始数据上。
此外,还有⼀个等价的实例⽅法也可以截取两个⽇期之间TimeSeries:
ts.truncate(after='1/9/2011')
⾯这些操作对DataFrame也有效。例如,对DataFrame的⾏进⾏索引:
# freq:频率,参数详情(https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#timeseries-offset-aliases)
dates = pd.date_range('1/1/2000', periods=100, freq='W-WED')
long_df = pd.DataFrame(np.random.randn(100, 4),
index=dates,
columns=['Colorado', 'Texas',
'New York', 'Ohio'])
long_df.loc['5-2001']
在某些应⽤场景中,可能会存在多个观测数据落在同⼀个时间点上的情况。下⾯就是⼀个例⼦:
dates = pd.DatetimeIndex(['1/1/2000', '1/2/2000', '1/2/2000',
'1/2/2000', '1/3/2000'])
dup_ts = pd.Series(np.arange(5), index=dates)
dup_ts
通过检查索引的is_unique属性,我们就可以知道它是不是唯⼀的:
dup_ts.index.is_unique
对这个时间序列进⾏索引,要么产⽣标量值,要么产⽣切⽚,具体要看所选的时间点是否重复:
dup_ts['1/3/2000'] # 未重复
dup_ts['1/2/2000'] # 重复
假设你想要对具有⾮唯⼀时间戳的数据进⾏聚合。⼀个办法是使⽤groupby,并传⼊level=0
:
grouped = dup_ts.groupby(level=0)
grouped.mean()
grouped.count()
pandas中的原⽣时间序列⼀般被认为是不规则的,也就是说,它们没有固定的频率。对于⼤部分应⽤程序⽽⾔,这是⽆所谓的。但是,它常常需要以某种相对固定的频率进⾏分析,⽐如每⽇、每⽉、每15分钟等(这样⾃然会在时间序列中引⼊缺失值)。幸运的是,pandas有⼀整套标准时间序列频率以及⽤于重采样、频率推断、⽣成固定频率⽇期范围的⼯具。例如,我们可以将之前那个时间序列转换为⼀个具有固定频率(每⽇)的时间序列,只需调⽤resample即可:
ts
# 字符串“D”是每天的意思,参数详情(https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#timeseries-offset-aliases)
resampler = ts.resample('D')
频率的转换(或重采样)是⼀个⽐较⼤的主题,稍后将专⻔⽤⼀节来进⾏讨论。这⾥,我将告诉你如何使⽤基本的频率和它的倍数。
pandas.date_range
可⽤于根据指定的频率⽣成指定⻓度的DatetimeIndex:
index = pd.date_range('2012-04-01', '2012-06-01')
index
默认情况下,date_range会产⽣按天计算的时间点。如果只传⼊起始或结束⽇期,那就还得传⼊⼀个表示⼀段时间的数字:
pd.date_range(start='2012-04-01', periods=20)
pd.date_range(end='2012-06-01', periods=20)
起始和结束⽇期定义了⽇期索引的严格边界。例如,如果你想要⽣成⼀个由每⽉最后⼀个⼯作⽇组成的⽇期索引,可以传⼊"BM"频率(表示business end of month,表11-4是频率列表),这样就只会包含时间间隔内(或刚好在边界上的)符合频率要求的⽇期:
表11-4 基本的时间序列频率(不完整)
时间序列频率,同上面:freq:频率,参数详情一致
pd.date_range('2000-01-01', '2000-12-01', freq='BM')
date_range默认会保留起始和结束时间戳的时间信息(如果有的话):
pd.date_range('2012-05-02 12:56:31', periods=5)
有时,虽然起始和结束⽇期带有时间信息,但你希望产⽣⼀组被规范化(normalize)到午夜的时间戳。normalize选项即可实现该功能:
pd.date_range('2012-05-02 12:56:31', periods=5, normalize=True)
pandas中的频率是由⼀个基础频率(base frequency)和⼀个乘数组成的。基础频率通常以⼀个字符串别名表示,⽐如"M"表示每⽉,"H"表示每⼩时。对于每个基础频率,都有⼀个被称为⽇期偏移量(date offset)的对象与之对应。例如,按⼩时计算的频率可以⽤Hour类表示:
from pandas.tseries.offsets import Hour, Minute
hour = Hour()
hour
four_hours = Hour(4)
four_hours
⼀般来说,⽆需明确创建这样的对象,只需使⽤诸如"H"或"4H"这样的字符串别名即可。在基础频率前⾯放上⼀个整数即可创建倍数:
pd.date_range('2000-01-01', '2000-01-03 23:59', freq='4h')
Hour(2) + Minute(30)
同理,你也可以传⼊频率字符串(如"2h30min"),这种字符串可以被⾼效地解析为等效的表达式:
pd.date_range('2000-01-01', periods=10, freq='1h30min')
有些频率所描述的时间点并不是均匀分隔的。例如,“M”(⽇历⽉末)和"BM"(每⽉最后⼀个⼯作⽇)就取决于每⽉的天数,对于后者,还要考虑⽉末是不是周末。由于没有更好的术语,我将这些称为锚点偏移量(anchored offset)。
表11-4列出了pandas中的频率代码和⽇期偏移量类。
笔记:⽤户可以根据实际需求⾃定义⼀些频率类以便提供pandas所没有的⽇期逻辑,但具体的细节超出了本书的范围。
WOM(Week Of Month)是⼀种⾮常实⽤的频率类,它以WOM开头。它使你能获得诸如“每⽉第3个星期五”之类的⽇期:
rng = pd.date_range('2012-01-01', '2012-09-01', freq='WOM-3FRI')
list(rng)
移动(shifting)指的是沿着时间轴将数据前移或后移。Series和DataFrame都有⼀个shift⽅法⽤于执⾏单纯的前移或后移操作,保持索引不变:
ts = pd.Series(np.random.randn(4),
index=pd.date_range('1/1/2000', periods=4, freq='M'))
ts
ts.shift(2)
ts.shift(-2)
当我们这样进⾏移动时,就会在时间序列的前⾯或后⾯产⽣缺失数据。
shift通常⽤于计算⼀个时间序列或多个时间序列(如DataFrame的列)中的百分⽐变化。可以这样表达:
ts / ts.shift(1) - 1
由于单纯的移位操作不会修改索引,所以部分数据会被丢弃。因此,如果频率已知,则可以将其传给shift以便实现对时间戳进⾏位移⽽不是对数据进⾏简单位移:
ts.shift(2, freq='M') # 将月份后移两月
这⾥还可以使⽤其他频率,于是你就能⾮常灵活地对数据进⾏超前和滞后处理了:
ts.shift(3, freq='D')
ts.shift(1, freq='90T')
pandas的⽇期偏移量还可以⽤在datetime或Timestamp对象上:
from pandas.tseries.offsets import Day, MonthEnd
now = datetime(2011, 11, 17)
now + 3 * Day()
如果加的是锚点偏移量(⽐如MonthEnd),第⼀次增量会将原⽇期向前滚动到符合频率规则的下⼀个⽇期:
now + MonthEnd()
now + MonthEnd(2)
通过锚点偏移量的rollforward和rollback⽅法,可明确地将⽇期向前或向后“滚动”:
offset = MonthEnd()
offset.rollforward(now)
offset.rollback(now)
⽇期偏移量还有⼀个巧妙的⽤法,即结合groupby使⽤这两个“滚动”⽅法:
ts = pd.Series(np.random.randn(20),
index=pd.date_range('1/15/2000', periods=20, freq='4d'))
ts
ts.groupby(offset.rollforward).mean()
当然,更简单、更快速地实现该功能的办法是使⽤resample(后面将进行详细介绍):
ts.resample('M').mean()
时间序列处理⼯作中最让⼈不爽的就是对时区的处理。许多⼈都选择以协调世界时(UTC,它是格林尼治标准时间(Greenwich Mean Time)的接替者,⽬前已经是国际标准了)来处理时间序列。时区是以UTC偏移量的形式表示的。例如,夏令时期间,纽约⽐UTC慢4⼩时,⽽在全年其他时间则⽐UTC慢5⼩时。
在Python中,时区信息来⾃第三⽅库pytz,它使Python可以使⽤Olson数据库(汇编了世界时区信息)。这对历史数据⾮常重要,这是因为由于各地政府的各种突发奇想,夏令时转变⽇期(甚⾄UTC偏移量)已经发⽣过多次改变了。就拿美国来说,DST转变时间⾃1900年以来就改变过多次!
有关pytz库的更多信息,请查阅其⽂档。就本书⽽⾔,由于pandas包装了pytz的功能,因此你可以不⽤记忆其API,只要记得时区的名称即可。时区名可以在shell中看到,也可以通过⽂档查看:
import pytz
pytz.common_timezones[-5:]
要从pytz中获取时区对象,使⽤pytz.timezone即可:
tz = pytz.timezone('America/New_York')
tz
默认情况下,pandas中的时间序列是单纯的(naive)时区。看看下⾯这个时间序列:
rng = pd.date_range('3/9/2012 9:30', periods=6, freq='D')
ts = pd.Series(np.random.randn(len(rng)), index=rng)
ts
print(ts.index.tz)
可以⽤时区集⽣成⽇期范围:
pd.date_range('3/9/2012 9:30', periods=10, freq='D', tz='UTC')
从单纯到本地化的转换是通过tz_localize⽅法处理的:
ts_utc = ts.tz_localize('UTC')
ts_utc
ts_utc.index
⼀旦时间序列被本地化到某个特定时区,就可以⽤tz_convert将其转换到别的时区了:
ts_utc.tz_convert('America/New_York')
对于上⾯这种时间序列(它跨越了美国东部时区的夏令时转变期),我们可以将其本地化到EST,然后转换为UTC或柏林时间:
ts_eastern = ts.tz_localize('America/New_York')
ts_eastern.tz_convert('UTC')
ts_eastern.tz_convert('Europe/Berlin')
tz_localize和tz_convert也是DatetimeIndex的实例⽅法:
ts.index.tz_localize('Asia/Shanghai')
注意:对单纯时间戳的本地化操作还会检查夏令时转变期附近容易混淆或不存在的时间。
跟时间序列和⽇期范围差不多,独⽴的Timestamp对象也能被从单纯型(naive)本地化为时区意识型(time zone-aware),并从⼀个时区转换到另⼀个时区:
stamp = pd.Timestamp('2011-03-12 04:00')
stamp
stamp_utc = stamp.tz_localize('utc')
stamp_utc
stamp_utc.tz_convert('America/New_York')
stamp_moscow = pd.Timestamp('2011-03-12 04:00', tz='Europe/Moscow')
stamp_moscow
时区意识型Timestamp对象在内部保存了⼀个UTC时间戳值(⾃UNIX纪元(1970年1⽉1⽇)算起的纳秒数)。这个UTC值在时区转换过程中是不会发⽣变化的:
stamp_utc.value
stamp_utc.tz_convert('America/New_York').value
当使⽤pandas的DateOffset对象执⾏时间算术运算时,运算过程会⾃动关注是否存在夏令时转变期。这⾥,我们创建了在DST转变之前的时间戳。⾸先,来看夏令时转变前的30分钟:
from pandas.tseries.offsets import Hour
stamp = pd.Timestamp('2012-03-12 01:30', tz='US/Eastern')
stamp
stamp + Hour()
然后,夏令时转变前90分钟:
stamp = pd.Timestamp('2012-11-04 00:30', tz='US/Eastern')
stamp
stamp + 2 * Hour()
如果两个时间序列的时区不同,在将它们合并到⼀起时,最终结果就会是UTC。由于时间戳其实是以UTC存储的,所以这是⼀个很简单的运算,并不需要发⽣任何转换:
rng = pd.date_range('3/7/2012 9:30', periods=10, freq='B')
ts = pd.Series(np.random.randn(len(rng)), index=rng)
ts
ts1 = ts[:7].tz_localize('Europe/London')
ts1
ts2 = ts1[2:].tz_convert('Europe/Moscow')
ts2
result = ts1 + ts2
result.index