时间序列数据是许多不同领域结构化数据的重要形式,如金融、经济、生态、神经科学和物理学。在许多时间点重复记录的任何内容都会形成一个时间序列。许多时间序列都是固定频率,也就是说数据点回根据某些规律以固定的时间间隔出现,例如每15秒,每5分钟或者每月一次。时间序列也可能是不规则的,没有固定的时间单位或者单位之间的偏移。标记和引用时间序列的方式取决于应用程序,并且你可能具有以下情况之一:
在本章中,主要关注前三类中的时间序列,尽管许多技术可以应用于实验时间序列,其中索引可以是整数或浮点数,表示实验开始经过的时间。最简单的时间序列类型是按时间戳索引。
注意:pandas还支持基于timedeltas的索引,它是表示实验或经过时间的有用方法,本书中我们不探讨timedeltas索引,但你可以在pandas documentation中了解更多信息。
pandas提供了许多内置的时间序列工具和算法。你可以高效地处理大型时间序列,并对不规则和固定频率的时间序列进行切片和切块、聚合和重新采样。其中一些工具对金融和经济应用很有用,但你也可以使用它来分析服务器日志数据。
先导入NumPy和pandas开始:
import numpy as np
import pandas as pd
Python标准库包括日期和时间数据的数据类型,以及与日历相关的功能。datetime,time和calendar模块是开始的主要位置。
# datetime.datetime类型,或者简化为datetime,被广泛使用
from datetime import datetime
now = datetime.now() # 返回datetime.datetime(2022, 10, 19, 13, 52, 37, 733867)
now.year, now.month, now.day # 返回(2022, 10, 19)
# datetime将日期和时间存储到微秒(10的负六次方秒)。
# datetime.timedelta,或者简化为timedelta,代表两个对象之间的时间差
delta = datetime(2011, 1, 7) - datetime(2008, 6, 24, 8, 15) # 返回datetime.timedelta(days=926, seconds=56700)
delta.days # 返回926
delta.seconds # 返回56700
# 你可以向datetime对象增加(或减去)一个或者多个timedelta对象以生成新的对象
from datetime import timedelta
start = datetime(2011, 1, 7)
start + timedelta(12) # 返回datetime.datetime(2011, 1, 19, 0, 0)
start - 2 * timedelta(12) # 返回datetime.datetime(2010, 12, 14, 0, 0)
下表总结了datetime模块中的数据类型。虽然本章主要关注pandas中的数据类型和更高级别的时间序列操作,但你可能会在Python的许多其他地方遇到基于datetime的类型。
[表]datetime模块中的类型
你可以使用str或者strftime方法,并传递格式规范,把datetime对象和pandasTimestamp对象格式化为字符串:
stamp = datetime(2011, 1, 3) # 创建datetime对象
str(stamp) # 直接转化为字符串:返回'2011-01-03 00:00:00'
stamp.strftime('%Y-%m-%d') # 按照格式将datetime转化为字符串:'2011-01-03'
下表总结了datetime格式化规范。
[表]datetime格式化规范(ISO C89标准)
你可以使用相同格式的代码,利用datetime.strptime将字符串转化为日期(但是一些代码,如’%F’不能使用)
value = '2011-01-03' # 构建字符串
datetime.strptime(value, '%Y-%m-%d') # 利用datetime.strptime将字符串按照格式'%Y-%m-%d'解析为datetime: 返回datetime.datetime(2011, 1, 3, 0, 0)
datestrs = ['7/6/2011', '8/6/2011'] # 字符串数组
[datetime.strptime(x, '%m/%d/%Y') for x in datestrs] # 按照'%m/%d/%Y'解析为datetime:返回datetime的数组
datetime.strptime是使用已知格式解析日期的一种方法。
pandas通常免洗那个日期数组,无论是用作轴索引还是DataFrame中的列。pandas.to_datetime方法解析许多不同类型的日期表示形式。可以快速解析IOS 8602等标准日期格式:
datestrs = ['2011-07-06 12:00:00', '2011-08-06 00:00:00']
pd.to_datetime(datestrs) # 返回:DatetimeIndex(['2011-07-06 12:00:00', '2011-08-06 00:00:00'], dtype='datetime64[ns]', freq=None)
# 它也可以处理缺失值(None, 空白字符串等)
idx = p.to_datetime(datestrs + [None])
idx[2] # 返回NaT
pd.isna(idx) # 返回布尔数组:array([False, False, True])
# NaT(不是时间)是pandas的timestamp数据的空值
注意:dateutil.parser是一个有用但不完美的工具。值得注意的是,它会将一些字符串识别为你可能希望它没有的日期,例如,'42’会被解析为今天日历日期的年份
datetime对象还为其他国家/地区或语言的系统提供了许多会根据区域而差异化呈现的格式设置选项。例如,与英语系统相比,德语或法语系统上的缩写月份名称将有所不同。
下表总结了特定于区域的日期格式。
[表]特定于区域的日期格式
pandas中一种基本的时间序列对象是按时间戳索引的Series,它通常在pandas之外表示Python字符串或者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.standard_normal(6), index=dates) # 构建行索引为datetime的series
# 这些datetime对象已经被放入DatetimeIndex对象中
ts.index # 返回:DatetimeIndex(['2011-01-02', '2011-01-05', ... , '2011-01-12'], dtype='datetime64[ns]', freq=None)
# 与其他series一样,不同索引的时间序列之间的算术运算回自动按日期对齐
ts + ts[::2] # 求和运算中,成员缺失的位置,在和的对应位置为NaN
# pandas使用NumPy的datetime64数据类型以纳秒精度村粗时间戳:
ts.index.dtype # 返回dtype('
一个pandas.Timestamp可以在使用datetime对象的大多数位置替换。然而,反之却不然,因为pandas.Timestamp可以存储纳秒级精度数据,而datetime最多只能存储到微秒。此外,pandas.Timestamp可以存储频率信息(如果有)并了解如何进行时区转换和其他类型操作。稍后将在’时区处理’中详细介绍这两件事。
当你根据标签索引和选择数据时,时间序列和其他series的行为类似:
stamp = ts.index[2]
ts[stamp]
# 方便起见,还可以传递可解释为日期的字符作为索引:
ts['2011-01-10']
# 对较长的时间序列,可以传递年份或仅年份和月份来选择数据切片(pandas.date_range将在'生成日期范围'中更详细讨论):
longer_ts = pd.Series(np.random.standard_normal(1000), index=pd.date_range('2000-01-01', periods=1000))
longer_ts['2001'] # 此处字符串'2001'被解释为年份并选择该时间段。
# 指定月份,也适用:
longer_ts['2001-05']
# 用datetime对象进行切片也有效
ts[datetime(2001, 1, 7):] # 返回索引为datetime(2001, 1, 7)之后的数据
ts[datetime(2001, 1, 7):datetime(2011, 1, 10)] # 返回索引为datetime(2001, 1, 7)和datetime(2011, 1, 10)之间的数据
# 由于大多数时间序列数据是按照时间顺序排序的,因此可以使用时间序列中不包含的时间戳进行切片,以执行范围查询:
ts['2011-01-06':'2011-01-11'] # 返回索引在'2011-01-06'和'2011-01-11'之间的数据
# 与前面一样,你可以传递字符串日期、datetime,或者时间戳。但请记住,以这种方式切片会生成原时间序列的视图,就像切片NumPy数组一样。
# 这意味着不会复制任何数据,并且对切片的修改会反映在原始数据中
# truncate方法可以在两个日期之间分割Series
ts.truncate(after='2011-01-09') # 删除索引值在'2011-01-09'后的所有行
# 所有这些都适用于DataFrame
dates = pd.date_range('2000-01-01', periods=100, freq='W-WED') # freq指定了间隔时间,'W-WED'表示指定每周星期三
long_df = pd.DataFrame(np.random.standard_normal((100, 4)),
index=dates,
columns=['Colorado', 'Texas', 'New York', 'Ohio']) # 构建DataFrame
long_df.loc['2001-05'] # 选择索引为'2001年05月'对应的数据
在某些应用程序中,可能会有多个数据观测值落在特定的时间戳上。
dates = pd.DatetimeIndex(['2000-01-01', '2000-01-02', '2000-01-02', '2000-01-02', '2000-01-03'])
dup_ts = pd.Series(np.arange(5), index=dates) # 构建Series
# 可以通过检查索引的is_unique属性来判断索引是否是唯一的
dup_ts.index.is_unique # 返回False,说明索引不唯一
# 对时间序列进行索引生成标量值或切片,取决于时间戳是否重复
dup_ts['2000-01-03'] # 该时间戳不重复,则返回标量
dup_ts['2000-01-02'] # 该时间戳重复,则返回series
# 假设你要聚合具有非唯一时间戳的数据。执行此操作的一种方法是使用groupby方法并传递level=0(有且唯一的层)
grouped = dup_ts.groupby(level=0) # 对第0层索引分组
grouped.mean() # 组内关于值求均值
grouped.count() # 组内关于非空值计数
pandas的一般时间序列被认为是不规则的;即。它们没有固定的频率。对于许多应用程序,这就足够了。但是,通常希望相对于固定频率(例如每天、每月或每十五分钟)工作,即使这意味着在时间序列中引入缺失值。幸运的是,pandas拥有一整套标准时间序列频率和用于重采样的工具(稍后在’重采样和频率转换’中会更详细地讨论),推断频率和生成固定频率日期范围。
# 你可以通过调用resample函数将采样时间序列转化为固定每日频率
resampler = ts.resample('D') # 返回pandas.core.resample.DatetimeIndexResampler对象,字符串'D'被解释为每日频率
频率之间的转换和重采样是一个足够大的主题,以后有相关小节’重采样和频率转换’。这里,将展示如何使用基本频率及其倍数。
# pandas.date_range负责根据特定频率产生具有指示长度的DatetimeIndex:
index = pd.date_range('2012-04-01', '2012-06-01') # 默认情况下,pandas.date_range生成每日时间戳,返回DatetimeIndex,范围从'2012-04-01'到'2012-06-01'
# 如果只传递开始日期或结束日期,则必须传递一个period值才能生成DatetimeIndex:
pd.date_range(start='2012-04-01', periods=20) # 按每日频率,从'2012-04-01'开始,生成含20个日期的DatetimeIndex
pd.date_range(end='2012-06-01', period=20) # 按每日频率,以'2012-06-01'结束,生成含20个日期的DatetimeIndex
# 开始日期和结束日期为生成的日期索引定义严格边界
# 如果想要一个包含每个月最后一个工作日的日期索引,则可以传递'BM'频率(business end of month,见下表了解更完整的频率列表),并且仅包含日期间隔或日期间隔内的日期
pd.date_range('2000-01-01', '2000-12-01', freq='BM')
下表是部分时间序列频率
[表]基本时间序列频率(不全面)
# pandas.date_range默认情况下保留开始或结束时间戳的时间(如果有的话)
pd.date_range('2012-05-02 12:56:31', periods=5)
# 有时,你会有包含时间信息(时分秒)的开始或结束日期,但希望生成一组规范化为午夜0:00的时间戳。为了执行此操作,可以使用normalize参数
pd.date_range('2012-05-02 12:56:31' periods=5, normalize=True) # 返回如'2012-05-02', '2012-05-03'构成的DatetimeIndex
pandas的频率由基本频率和乘数组成。基本频率通常由字符串别名引用,例如’M’表示每月或’H’表示每小时。对于每个基本频率,都有一个称为日期偏移量的对象。例如,每小时频率可以用Hour类表示:
from pandas.tseries.offsets import Hour, Minute
hour = Hour()
hour # 返回:类的实例
# 可以传递整数来定义偏移量的倍数
four_hours = Hour(4)
four_hours # 返回:<4 * Hours>
# 在大多数应用程序中,你不需要显示创建这些对象。相反,你可以使用字符串别名,如'H'或'4H'。在基本频率前放置一个整数将创建一个倍数
pd.date_range('2000-01-01', '2000-01-03 23:59', freq='4H')
# 许多偏移可以通过加法组合
Hour(2) + Minute(30) # 返回:<150 * Minutes>
# 类似地,你可以传递频率字符串,例如,'1h30min'会被有效解析为相同的表达式:
pd.date_range('2000-01-01', periods=10, freq='1h30min')
一些频率描述的时间点不是均匀分布的。例如,‘M’(每个月的最后一个日历日)和’BM’(每个月的最后一个工作日)取决于月的天数;在后一种情况下,取决于月份是否在周末结束。我们将这些称为锚定偏移。
注意:用户可以定义自定义频率类,以提供pandas中没有的日期逻辑,尽管全部细节不在本书讨论范围内。
一个有用的频率类是’每个月的一周’,以WOM开始。下列代码使你可以获得每个月的第三个星期五:
monthly_dates = pd.date_range('2012-01-01', '2012-09-01', freq='WOM-3FRI') # 从2012-01-01到2012-09-01的每个月的第三个星期五
移位是指随时间向后或向前移动数据。Series和DataFrame都有一种向前或向后执行朴素移位的shift方法,使索引保持不变:
ts = pd.Series(np.random.standard_normal(4), index=pd.date_range('2000-01-01', periods=4, freq='M'))
ts.shift(2) # 数据值向后移2位,前2位变缺失值
ts.shift(-2) # 数据值向前移2位,后2位变缺失值
# 当我们像这样移动时,缺失的数据会在时间序列的开始或结束中引入
# shift的常见用途时将时间序列或多个时间序列中的连续百分比变化计算为DataFrame列
ts / ts.shift(1) - 1
# 由于朴素移位未修改索引,因此会丢弃某些数据。因此,如果频率已知,则可以将它传递到shift方法以推进时间戳而不是仅仅是数据
ts.shift(2, freq='M') # 时间戳按照频率'M'往后移2位,即移动到2个月后的最后一个日历日
# 也可以传递其他频率
ts.shift(3, freq='D') # 时间戳按照频率'D'往后移3位,即移动到3天后
ts.shift(1, freq='90T') # 'T'表示分钟。时间戳按照频率'90T'往后移动1位,即移动到1小时30分后
# 注意:freq参数指示要应用于时间戳的偏移量,但它不会更改数据的基础频率(如果有的话)
pandas 日期偏移也可以与datetime或Timestamp对象一起使用:
from pandas.tseries.offsets import Day, MonthEnd
now = datetime(2011, 11, 17)
now + 3 * Day() # 返回Timestamp('2011-11-20 00:00:00')
# 如果你要添加如'MonthEnd'的锚定偏移量,则第一个增量将根据频率规则将日期'前滚'到下一个日期:
now + MonthEnd() # 日期移动到'2011-11-30'
now + MonthEnd(2) # 日期移动到'2011-12-31'
# 锚定偏移量可以明确地向前或向后'滚动'日期,只需要分别使用它们的rollforward和rollback方法:
offset = MonthEnd()
offset.rollforward(now) # 从now日期按照日期偏移往后滚
offset.rollback(now) # 从now日期按照日期偏移往前滚
# 日期偏移量的创造性用法是将这些方法与groupby结合:
ts = pd.Series(np.random.standard_normal(20), index=pd.date_range('2000-01-15', periods=20, freq='4D')
ts.groupby(MonthEnd().rollforward).mean() # 按照经过MonthEnd().rollforward后的值相同为同组,组内对应的值酋均值
# 一种更简单、更快捷的方法是用resample方法(我们将在'重采样和频率转换'中更深入探讨这个问题):
ts.resample('M').mean()
参考资料:
使用时区可能是时间序列操作中最令人不快的部分之一。因此,许多时间序列用户选择使用协调世界时或UTC时间序列,这是与地理无关的国际标准。时区表示为与UTC的偏移量,如,在夏令时(DST),纽约比UTC晚4个小时,一年的其余时间,纽约比UTC晚5小时。
在Python中,时区信息来自于第三方pytz库(可以使用pip或conda安装),该库公开了Olson数据库,这是世界时区信息的汇编。这对于历史数据尤其重要,因为DST转换日期(甚至UTC偏移量)已根据区域法律进行了多次修改。在美国,自1990年以来,DST转换时间已更改多次!
有关pytz库的详细信息,你需要查看该库的文档。就本书而言,pandas包装了pytz的功能,所以你可以忽略时区名称之外的API。由于pandas对pytz的依赖,因此没有必要单独安装他。时区名称可以通过交互方式在文档中找到:
import pytz
pytz.common_timezones[-5:] # 返回最后5个时区
# 要从pytz中获取时区对象,可以使用pytz.timezon函数:
tz = pytz.timezone('America/New_York') # 返回时区对象
# pandas的方法将接受时区名称或者这些对象
默认情况下,pandas的时区序列是朴素时区。例如,考虑下面时间序列:
dates = pd.date_range('2012-03-09 09:30', periods=6)
ts = pd.Series(np.random.standard_normal(len(dates)), index=dates)
print(ts.index.tz) # 索引的tz字段为None
# 可以使用设置的时区生成日期范围
pd.date_range('2012-03-09 09:30', periods=6, tz='UTC')
# 从朴素到本地化(重新解释为在特定时区中观察到)的转化由tz_localize方法实现:
ts_utc = ts.tz_localize('UTC')
ts_utc.index
# 一旦时间序列被本地化为特定时区,它可以通过tz_convert函数转换为另一个时区
ts_utc.tz_convert('America/New_York')
# 先本地化为'America/New_York'时区,再转换为'UTC'或'Berlin'时区
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('Aisa/Shanghai')
注意:本地化朴素时间戳还会检查夏令时转换前后的时间不明确或不存在的时间。
与时间序列和日期范围类似,单个时间戳对象同样可以从朴素本地化到某个时区,并从一个时区转换到另一个时区
stamp = pd.Timestamp('2011-03-12 04:00') # 创建时间戳对象
stamp_utc = stamp.tz_localize('utc') # 本地化为'utc'时区
stamp_utc.tz_convert('America/New_York') # 转换为'America/New_York'时区
# 可以在创建Timestamp时传递时区:
stamp_moscow = pd.Timestamp('2011-03-12 04:00', tz='Europe/Moscow')
# 时区识别的Timestamp对象在内部将UTC时间戳值存储为自Unix纪元(1970年1月1日)以来的纳秒级,因此更改时区不会更改内部的UTC值
stamp_utc.value # 返回1299902400000000000
stamp_utc.tz_convert('America/New_York').value # 仍返回1299902400000000000
# 在使用pandas的DateOffset对象执行时间算术时,pandas会尽可能尊重夏令时(Daylight Saving Time: DST)过渡。
# 这里,我们构造正好在DST过渡之前的时间戳。
# 首先,构造进入DST的30分钟前
stamp = pd.Timestamp('2012-03-11 01:30', tz='US/Eastern') # 返回:Timestamp('2012-03-11 01:30:00-0500', tz='US/Eastern')
stamp + Hour() # 因为进入夏令时,人为拨快1小时,进入-4时区,返回:Timestamp('2012-03-11 03:30:00-0400', tz='US/Eastern')
# 构造离开DST的90分钟前
stamp = pd.Timestamp('2012-11-03 00:30', tz='US/Eastern') # Timestamp('2012-11-04 00:30:00-0400', tz='US/Eastern')
stamp + 2 * Hour() # 返回:Timestamp('2012-11-04 01:30:00-0500', tz='US/Eastern')
如果将两个具有不同时区的时间序列组合在一起,结果会变成UTC。由于时间戳本质上以UTC格式存储,因此这是一个简单的操作,不需要转换:
dates = pd.date_range('2012-03-07 09:30', periods=10, freq='B')
ts = pd.Series(np.random.standard_normal(len(dates), index=dates)
ts1 = ts[:7].tz_localize('Europe/London')
ts2 = ts1[2:].tz_localize('Europe/Moscow') # 13:30:00+4:00表示是东4区,当地时间是13:30:00,换算成UTC时是9:30:00
result = ts1 + ts2
result.index # 返回UTC格式的时间序列索引
# 注意:不支持朴素时区数据(没有指定时区)和时区识别数据(有时区)之间的操作,这些操作会引发异常。
周期表示时间跨度,如天、月、季度或年。pandas.Period类表示此数据类型,需要一个字符串或整数以及’基本时间序列频率’表中支持的频率。
p = pd.Period('2011', freq='A-DEC') # Period对象表示从2011年1月1日到2011年12月31日(包含)的完整时间跨度
# 在周期中增加或减少整数具有改变其频率的效果
p + 5 # 返回Period('2016', 'A-DEC'),表示从2016年1月1日到2016年12月31日(包含)的完整时间跨度
p - 2 # 返回Period('2009', 'A-DEC')
# 如果两个周期具有相同的频率,则它们的差是作为日期偏移量的它们之间的单位数
pd.Period('2014', freq='A-DEC') - p # 返回<3 * YearEnds: month=12>
# 可以使用period_range函数构造PeriodIndex对象,表示周期的常规范围:
periods = pd.period_range('2000-01-01', '2000-06-30', freq='M') # 返回period[M]组成的PeriodIndex对象
# PeriodIndex类存储一系列周期,并且可以在任何pandas数据结构中用作轴索引
pd.Series(np.random.standard_normal(6), index=periods)
# 如果你有一个字符串数组,也可以用PeriodIndex函数构造PeriodIndex对象,其中所有值都是周期
values = ['2001Q3', '2002Q2', '2003Q1']
index = pd.PeriodIndex(values, freq='Q-DEC')
周期和PeriodIndex对象可以使用它们的asfreq方法转化为别的频率。
# 假设我们有一个年度周期,并希望在年初或年底将其转换为月度周期
p = pd.Period('2011', freq='A-DEC') # 构造2011年1月1日到2011年12月31日的年度周期
p.asfreq('M', how='start') # 利用asfreq转换为年初的月度周期,返回:Period('2011-01', 'M')
p.asfreq('M', how='end') # 返回Period('2011-12', 'M')
p.asfreq('M') # # asfreq的how参数默认使用'end',返回:Period('2011-12', 'M')
可以认为Period(‘2011’, ‘A-DEC’)是一种指向时间跨度的光标,按月度细分。有关此情况的说明,可以参考图11.1。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tt6G8dz0-1669683360558)(https://secure2.wostatic.cn/static/gj5ZLWdpRjAg3dTN22dY2F/image.png)]
# 对于12月以外的月份结束的年度财报,相应的月度子周期是不同的
p = pd.Period('2011', freq='A-JUN') # freq表示6月30号锚定的年度日期,所以表示从2010年7月1日到2011年6月30日(包含)的完整时间跨度
p.asfreq('M', how='start') # 返回Period('2010-07', 'M')
p.asfreq('M', how='end') # 返回Period('2011-06', 'M')
当你从高频周期转换为低频周期时,pandas会根据高频周期’所属的位置’确定低频周期。
# 例如,在'A-JUN'频率中,'Aug-2011'实际上是2012周期的一部分
p = pd.Period('Aug-2011', 'M') # 表示从2011年8月1日到2011年8月31日的时间跨度
p.asfreq('A-JUN') # 由于锚定日期8月31日在2011年7月1日到2012年6月31日之间,所以'A-JUN'决定的周期为2011年7月1日到2012年6月31日,所以返回Period('2012', 'A-JUN')
整个PeriodIndex对象或者时间序列可以使用相同的语义进行类似的频率转换:
periods = pd.period_range('2006', '2009', freq='A-DEC') # 创建PeriodIndex对象
ts = pd.Series(np.random.standard_normal(len(periods)), index=periods) # 创建时间序列
# 时间序列使用类似PeriodsIndex频率转换的方法,将低频周期索引转化为高频周期索引
ts.asfreq('M', how='start) # 年度周期被替换为对应于每个年度周期内的第一个月的月度周期
ts.asfreq('B', how='end') # 年度周期被替换为对应于每个年度周期的最后一个工作日的日周期
季度数据是会计、财务和其他领域的标准数据。许多季度数据都是相对于财政年度结束报告的,通常是一年中12个月之一的最后一个日历日或者工作日。因此,根据会计年度结束的不同,2012Q4有不同的含义,pandas支持12种可能的季度频率,如Q-JAN到Q-DEC:
p = pd.Period('2012Q4', freq='Q-JAN')
# 'Q-JAN'表示1月31日锚定的季度日期(第四季度:'11月1日到1月31日')
# 对于1月结束的会计年,2012Q4表示从2011年11月1日到2012年1月31日,你可以通过转化为每日频率来检查
p.asfreq('D', how='start') # 返回周期内的第一个日周期:Period('2011-11-01', 'D')
p.asfreq('D', how='end') # 返回周期内的最后一个日周期:Period('2012-01-31', 'D')
图11.2展示了不同季度频率约定。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2A8OElct-1669683360559)(https://secure2.wostatic.cn/static/wbtHFAyJUxHecVznwZCvfu/image.png)]
因此,可以进行方便的算术运算。
# 例如,要在p周期的季度倒数第二个工作日的下午4点获得时间戳
p4pm = (p.asfreq('B', how='end') - 1).asfreq('T', how='start') + 16 * 60 # 返回Period('2012-01-30 16:00', 'T'),表示16:00:00到16:00:59的周期
p4pm.to_timestamp() # to_timestamp方法默认返回周期的开头时间戳,这里返回Timestamp('2012-01-30 16:00:00')
# 可以使用pandas.period_range方法生成季度范围。算术运算也是相同的:
periods = pd.period_range('2011Q3', '2012Q4', freq='Q-JAN')
ts = pd.Series(np.arange(len(periods)), index=periods)
new_periods = (periods.asfreq('B', 'end') - 1).asfreq('H', 'start') + 16 # 季度周期被替换为每个季度倒数第二天第十六个小时
ts.index = new_periods.to_timestamp() # 将周期转化为时间戳,即将PeriodIndex转化为DatetimeIndex
可以通过to_period方法将按时间戳索引的Seires和DataFrame对象转化为周期:
dates = pd.date_range('2000-01-01', periods=3, freq='M') # 创建DatetimeIndex对象,返回['2000-01-31', '2000-02-29', '2000-03-31']
ts = pd.Series(np.random.standard_normal(3), index=dates) # 构造Series
pts = ts.to_period() # 将DatetimeIndex索引转化为PeriodIndex索引,即将Timestamp转化为Period
由于周期是指不重叠的时间跨度,因此时间戳只能属于给定频率的单个周期。虽然默认情况下,新的PeriodIndex的频率是从时间戳中推断出来的,但你可以指定任何支持的索引。结果中有重复的周期也没有问题:
dates = pd.date_range('2000-01-29', period=6) # 创建DatetimeIndex:['2000-01-29','2000-01-30','2000-01-31','2000-02-01','2000-02-02','2000-02-03']
ts2 = pd.Series(np.random.standard_normal(6), index=dates) # 创建Series
ts2.to_period('M') # 将Timestamp转化为Period,并且指定周期的频率为'M'
# 如果要转换回时间戳,则可以使用to_timestamp方法,它会返回一个DatetimeIndex:
pts = ts2.to_period() # 将Timestamp转化为Period,并推断指定周期的频率为'D'
pts.to_timestamp(how='end') # 将Period转化为时间戳,通过how='end'要求返回周期最后一个时间戳(精确到59.999999999秒)
固有频率数据集有时与分布在多个列中的时间跨度信息一起存储。
# 例如,在此宏观经济数据集中,年份和季度位于不同的列中
data = pd.read_csv('examples/macrodata.csv')
data['year'] # 'year'列存储年份
data['quarter'] # 'quarter'列存储季度
# 通过将这些数组和一个频率传递给PeriodIndex,你可以将它们组合成DataFrame的索引
index = pd.PeriodIndex(year=data['year'], quarter=data['quarter'], freq='Q-DEC') # 创建PeriodIndex
data.index = index # 将创建的PeriodIndex作为DataFrame的索引
重采样是将时间序列从一个频率转换为另一个频率的过程。将较高频率的数据聚合到较低频率称为下采样,而将较低频率转换为较高频率称为上采样。并非所有的重采样都属于这两类之一。例如,转化W-WED(每周三)到W-FRI(每周五)既不是上采样也不是下采样。
pandas对象配备了一个resample方法,这是所有频率转换的主力函数。resample函数具有类似groupby的API;你可调用resample来对数据进行分组,然后调用聚合函数:
dates = pd.date_range('2000-01-01', period=100) # 创建DatetimeIndex
ts = pd.Series(np.random.standard_normal(len(dates)), index=dates) # 创建Series
ts.resample('M').mean() # 聚合到'M'频率的Timestamp(下采样),并对组内数据求平均值
ts.resample('M', kind='period').mean() # 聚合到'M'频率的Period(下采样),并对组内数据求平均值
resample是一种灵活的方法,可用于处理大型时间序列。以下各节的示例说明了其语义和用法。下表总结了它的一些选项。
[表]resample方法参数
下采样是将数据聚合到常规的较低频率。你正在聚合的数据不需要经常更改;所需频率定义了用于将时间序列切片以进行聚合的bin edges。例如,要转换为每月,‘M’或’BM’,你需要将数据切分为一个月的区间。每个区间被称为半开的:一个数据点只能属于一个区间,并且区间的并集必须构成整个时间范围。使用resample对数据进行下采样时,需要考虑以下几点:
为了说明这一点,让我们看一些一分钟的频率数据
dates = pd.date_range('2000-01-01', periods=12, freq='T') # 创建DatetimeIndex
ts = pd.Series(np.arange(len(dates)), index=dates)
# 假设你希望通过获取每个组的综合将此数据聚合为5分钟的块或者条形图
ts.resample('5min').sum() # 传递的频率定义了bin edges以5分钟为增量。对于此频率,默认情况下,左边缘是闭合的,所以00:00包含在00:00到00:05区间中,而00:05则排除
ts.resample('5min', closed='right').sum() # bin edges的右边缘是闭合的,所以00:00包含在区间(23:55,00:00]中
# 生成的时间序列默认由每个bin左侧的时间戳标记,但可以通过传递label='right'来用右侧的时间戳标记
ts.resample('5min', close='right', label='right').sum()
图11.3是帮助了解分数频率数据重采样为5分钟频率的图示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lbuQehy8-1669683360560)(https://secure2.wostatic.cn/static/3mVhj62TVa49eW8XXWfVHD/image.png)]
最后,你可能希望将结果索引移动一些量,例如从右边缘减去一秒钟,以便更清楚地了解时间戳所指的区间。为此,可以向生成的索引添加偏移量:
from pandas.tseries.frequencies import to_offset
result = ts.resample('5min', closed='right', label='right').sum() # 聚合的bin edges右端闭合,且用右端标记
result.index = result.index + to_offset('-1s')
在金融领域,聚合时间序列的一种常用方法是为每个存储桶计算四个值:第一个(开盘)、最后一个(收盘)、最大值(最高价)和最小值(低点)。通过使用ohlc聚合函数,你可以获得一个DataFrame,其中包含这四个聚合列:
ts = pd.Series(np.random.permutation(np.arange(len(dates))), index=dates)
ts.resample('5min').ohlc()
上采样是从较低频率转换为较高频率,无需聚合。让我们考虑一个包含一些每周数据的DataFrame:
frame = pd.DataFrame(np.random.standard_normal((2, 4)),
index=pd.date_range('2000-01-01', periods=2, freq='W-WED'),
columns=['Colorado', 'Texas', 'New York', 'Ohio']) # 创建时间戳索引为每周三的时间序列DataFrame
# 将聚合函数与此数据一起使用时,每个组只有一个值,缺失值回导致间隙
# 使用asfreq方法转换为更高的频率,而无需任何聚合
df_daily = frame.resample('D').asfreq() # 等效于frame.asfreq('D')
# 假设你想在非星期三上向前填充每个工作日的值,则fillna和reindex方法中可用的相同的填充或插值方法也可用于重采样:
frame.resample('D').ffill() # 向前填充
# 同样,你可以选择仅向前填充一定数量的周期,以限制继续使用观测值的距离:
frame.resample('D').ffill(limit=2)
# 值得注意的是,新的日期索引根本不需要与旧日期索引一致
frame.resample('W-THU').ffill() # 变更索引为每周四
按照周期索引的数据重采样类似于时间戳。
frame = pd.DataFrame(np.random.standard_normal((24, 4)),
index=pd.period_range('1-2000', '12-2001', freq='M'),
columns=['Colorado', 'Texas', 'New York', 'Ohio']) # 创建周期索引的DataFrame
annual_frame = frame.resample('A-DEC').mean() # 按照'A-DEC'频率分组,并对组内数据求均值
上采样更加微妙,因为在重采样前,你必须决定在新频率中的时间跨度的哪一端放置值。convention参数默认为’start’,但也可以是’end’:
# Q-DEC: 季度频率,以12月结束年(即12月为第四季度的最后一个月)
annual_frame.resample('Q-DEC').ffill() # 上采样,从年度周期转化为季度周期(值存放到年度周期中的第一个季度周期,不一定是Q1),并向前填充缺失值
annual_frame.resample('Q-DEC', convention='end).asfreq() # 值存放到年度周期的最后一个季度周期
由于周期是指时间跨度,因此有关上采样和下采样的规则更加严格:
如果不满足这些规则,将会引发异常。这主要影响季度,年度和每周频率;例如,由Q-MAR定义的时间跨度仅与A-MAR,A-JUN,A-SEP和A-DEC对齐
annual_frame.resample('Q-MAR').ffill() # 上采样,将年度周期转化为季度周期,并向前填充值
对于时间序列数据,resample方法在语义上是基于时间间隔的组操作。下面是一个小示例表:
N = 15
times = pd.date_range('2017-05-20 00:00', freq='1min', periods=N) # 创建长为15的DataTimeIndex,频率为1分钟
df = pd.DataFrame({'time': times, 'value': np.arange(N)}) # 创建DataFrame
# 按照'time'进行索引(得到时间序列),并进行重采样,频率为'5min'
df.set_index('time').resample('5min').count() # 组内计数
# 假设DataFrame包含多个时间序列
df2 = pd.DataFrame({'time': times.repeat(3), 'key': np.tile(['a', 'b', 'c'], 'value': np.arange(N * 3.)})
# 要对'key'的每个值进行相同的重采样,我们引入pandas.Grouper对象
time_key = pd.Grouper(freq='5min') # 创建频率为'5min'的pandas.Grouper对象
# 然后,我们可以设置时间索引,按照'key'和'time_key'分组,并聚合
resampled = (df2.set_index('time').groupby(['key', time_key]).sum())
resampled.reset_index() # 展开,将索引变成列
# 使用pandas.Grouper的一个约束是时间必须是Series或者DataFrame的索引
用于时间序列操作的一类重要数组转换是通过滑动窗口或指数衰减权重计算的统计信息和其他函数。这对于平滑嘈杂或者间隙数据非常有用。称它们为移动窗口函数,即使它们没有固定长度窗口。与其他统计函数一样,这些函数也会自动排除缺失值。
# 加载一些时间序列数据,并将其重采样到工作日频率
close_px_all = pd.read_csv('examples/stock_px.csv', parse_dates=True, index_col=0)
close_px = close_px_all[['AAPL', 'MSFT', 'XOM']]
close_px = close_px.resample('B').ffill()
下面介绍rolling运算符,它的行为类似于resample和groupby。它可以在Series或DataFrame与window(表示多个周期,见图11.4)一起调用:
close_px['AAPL'].plot()
close_px['AAPL'].rolling(250).mean().plot()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3bo1Lvza-1669683360560)(https://secure2.wostatic.cn/static/7TPYymtpVUAAR5gVBS96dK/image.png)]
表达式rolling(250)类似于groupby,但不是分组,而是创建一个对象,该对象允许在250天的滑动窗口中进行分组。因此,这里我们有苹果股价的250天滑动窗口平均值。
默认情况下,rolling函数要求窗口中的所有值都是非NA值。可以更改此行为以考虑缺失值,特别是在时间序列开始时,数据少于窗口周期的时候(见图11.5):
plt.figure()
std250 = close_px['AAPL'].pct_change().rolling(250, min_periods=10).std() # 允许最小窗口为10,即前10个数就可以作为一个滑动窗口分组
std250[5: 12]
std250.plot()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KhYj76MW-1669683360561)(https://secure2.wostatic.cn/static/7ZmqS5bHZm2PeAadJZ3PxH/image.png)]
要计算扩展窗口均值,需要使用expanding运算符而不是rolling运算符。扩展均值从与rolling winodw相同的点开始时间窗口,并增加窗口的大小,直到它包含整个序列。
# std250时间序列上的扩展窗口均值如下
expanding_mean = stad250.expanding().mean()
# 在DataFrame上调用移动窗口函数回将转换应用于每一列(见图11.6)
plt.style.use('grayscale')
close_px.rolling(60).mean().plot(logy=True)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HR9hNIS5-1669683360561)(https://secure2.wostatic.cn/static/gnpZzcTuk82XGjbaYf4GwL/image.png)]
rolling函数还接受一个字符串,指示移动窗口函数中固定大小的时间偏移,而不是一系列周期。使用此表示法可用于不规则时间序列。这些字符串就是可以传递给resample的字符串。
# 例如,可以计算一个20天滑动平均值
close_px.rolling('20D').mean() # 每个时间戳对应的窗口为该时间戳及其往前20天的时间戳之间的周期
使用具有相同权重观测值的固定窗口大小的代替方法是指定恒定衰减因子,以便为最近的观测值赋予更多权重。有几种方法可以指定衰减因子。一种流行的方法是使用跨度span。
y t = ( 1 − α ) y t − 1 + α x t y_t=(1-\alpha)y_{t-1}+\alpha x_t yt=(1−α)yt−1+αxt
α = 2 s p a n + 1 \alpha = \frac{2}{span+1} α=span+12
由于指数加权统计对最近的观测值给予了更多的权重,因此与等权重版本相比,它可以更快地’适应’变化。
pandas有ewm运算符(代表指数加权移动)。下面是一个示例,将Apple股价的30天移动平均线与span为60的指数加权(EW)移动平均线进行比较(见图11.7):
aapl_px = close_px['AAPL']['2006':'2007']
ma30 = aapl_px.rolling(30, min_periods=20).mean()
ewma30 = aapl_px.ewm(span=30).mean()
aapl_px.plot(style='k-', label='Price') # 价格曲线
ma30.plot(style='k--', label='Simple Moving Avg') # 简单滑动平均曲线
ewma30.plot(style='k-', label='EW MA') # 指数加权平均
plt.legend()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f0qKkJ0k-1669683360561)(https://secure2.wostatic.cn/static/e6Q1Crji67QiUZvZJmmGvx/image.png)]
一些统计运算符(如相关性和协方差)需要对两个时间序列进行操作。例如,金融分析师通常对股票与标准普尔500指数等基准指数的相关性感兴趣。
# 我们首先计算所有感兴趣的时间序列的百分比变化:
spx_px = close_px_all['SPX']
spx_rets = spx_px.pct_change()
returns = close_px.pct_change()
# 调用rolling函数后,corr聚合函数可以计算与spx_rets的滚动相关性(结果见图11.8)
corr = returns['AAPL'].rolling(125, min_periods=100).corr(spx_rets)
corr.plot()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qCq8eBPB-1669683360562)(https://secure2.wostatic.cn/static/cLBSxZwMiCGjggZywa5Ryp/image.png)]
假设你要同时计算标准普尔500指数与许多股票的滚动相关性。你可以像上面对Apple所做的那样,为每只股票编写一个循环来计算这一点,但如果每只股票都是单个DataFrame的一列,我们可以调用在DataFrame上调用rolling并传递spx_rets来一次性计算所有滚动相关性,结果见图11.9:
corr = returns.rolling(125, min_periods=100).corr(spx_rets)
corr.plot()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1DkfTBQa-1669683360562)(https://secure2.wostatic.cn/static/sBZYZKivEEdUtia6jeNtn3/image.png)]
rolling上的apply方法和相关方法提供了一种在移动窗口上应用自己创建的数组函数的方法。唯一的要求是函数从数组的每个部分生成单个值。例如,虽然我们可以使用rolling(…).quantile(q)来计算样本分位数,但我们可能对样本上特定值的百分位数该兴趣。scipy.stats.percentileofscore函数就是做这个的(见图11.10):
from scipy.stats import percentileofscore
def score_at_2percent(x):
return percentileofscore(x, 0.02)
result. returns['AAPL'].folling(250).apply(score_at_2percent)
result.plot()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LD5VlOtr-1669683360562)(https://secure2.wostatic.cn/static/a6n63fLhMbERkj8pvQA222/image.png)]