Python 数据分析基础入门:《Excel Python:飞速搞定数据分析与处理》学习笔记系列(第六章 使用 pandas 进行时序分析)
Excel Python:飞速搞定数据分析与处理

第二部分 pandas 入门
第六章 使用 pandas 进行时序分析
时序(time series)是时间轴上的一系列数据点,它们在很多场景中扮演着重要角色:交 易员用历史股价计算风险;天气预报基于测量温度、湿度和气压的传感器生成的时序来预测天气;数字市场部依靠网页生成的时序(比如每小时访问量)来得出营销活动所需的结论。
时序分析方面的需求使得数据科学家和分析师开始寻找更优秀的技术来替代Excel。他们的动机概括起来有以下几点。
-
大型数据集
时序的快速增长可能会使得数据量超过每张Excel数据表的容量上限——大约1 000 000 行。如果要在报价数据层面上处理盘中股价,那么你通常需要处理成千上万条记录,因 为每天、每只股票都会产生一条记录。
-
日期和时间
Excel 在处理时序的基石,即日期和时间时有很多限制。举例来说,Excel 缺少对时区和毫秒时间格式的支持。而 pandas 支持时区,且使用了 NumPy 的 datetime64[ns] 数据类型,这种数据类型的时间精度可以精确到纳秒。
-
缺少功能
Excel 甚至缺少处理时序数据的基本工具,例如,将每日时序转换为每月时序本来是一项十分常见的工作,但是在 Excel 中并没有一种方便的方法来完成。
利用 DataFrame 可以处理多种基于时间的索引:DatetimeIndex 是最常见的一种,表示带有时间戳的索引。其他的索引类型,比如 PeriodIndex,是基于时间间隔(比如每小时、每月)的索引。本章只会研究DatetimeIndex。
6.1 DatetimeIndex
本节会学习如何构造 DatetimeIndex,如何筛选属于特定时间范围的索引,以及如何处理时区。
6.1.1 创建 DatetimeIndex
pandas 为构造 DatetimeIndex 提供了 date_range 函数。它会接受一个开始日期、一个频率参数,以及周期数或者结束日期:
In [1]: # 首先导入本章中会用到的包,
# 并将绘图后端设置为Plotly
import pandas as pd
import numpy as np
pd.options.plotting.backend = "plotly"
In [2]: # 通过起始时间戳、周期数和频率
#("D"=daily)创建DatetimeIndex
daily_index = pd.date_range("2020-02-28", periods=4, freq="D")
daily_index
Out[2]: DatetimeIndex(['2020-02-28', '2020-02-29', '2020-03-01', '2020-03-02'],
dtype='datetime64[ns]', freq='D')
In [3]: # 通过起始/结束时间戳创建DatetimeIndex
# 将频率设置为每周星期日("W-SUN")
weekly_index = pd.date_range("2020-01-01", "2020-01-31", freq="W-SUN")
weekly_index
Out[3]: DatetimeIndex(['2020-01-05', '2020-01-12', '2020-01-19', '2020-01-26'],
dtype='datetime64[ns]', freq='W-SUN')
In [4]: # 通过weekly_index构造DatetimeIndex
# 可以作为只在星期日开放的博物馆的游客人数
pd.DataFrame(data=[21, 15, 33, 34],
columns=["visitors"], index=weekly_index)
Out[4]: visitors
2020-01-05 21
2020-01-12 15
2020-01-19 33
2020-01-26 34

现在回到第 5 章中微软股价的时序。如果仔细观察列的数据类型,你会注意到 Date列是 object 类型,也就是说pandas 会将时间戳作为字符串来解释:
In [5]: msft = pd.read_csv("csv/MSFT.csv")
In [6]: msft.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 8622 entries, 0 to 8621
Data columns (total 7 columns):
# Column Non-Null Count Dtype --- ------ -------------- -----
0 Date 8622 non-null object
1 Open 8622 non-null float64
2 High 8622 non-null float64
3 Low 8622 non-null float64
4 Close 8622 non-null float64
5 Adj Close 8622 non-null float64
6 Volume 8622 non-null int64
dtypes: float64(5), int64(1), object(1)
memory usage: 471.6+ KB

有两种方法可以修复这个问题并将其转换为 datetime 数据类型。一种方法是在该列上执行 to_datetime 函数。如果你想直接对原本的 DataFrame 进行修改,那么一定要记得将修改后的列赋值给原本的 DataFrame:
In [7]: msft.loc[:, "Date"] = pd.to_datetime(msft["Date"])
In [8]: msft.dtypes
Out[8]: Date datetime64[ns]
Open float64
High float64
Low float64
Close float64
Adj Close float64
Volume int64
dtype: object

另一种方法是通过 parse_dates 参数告诉 read_csv 这一列包含时间戳。parse_dates 会接受一个列名列表或者索引作为参数。另外,你可能总是需要将时间戳转换为 DataFrame 的索引,因为我们马上会看到,以时间戳为索引可以让筛选数据更加简单。在 index_col 参数中提供想要用作索引的列名或索引可以省去一次 set_index 调用:
In [9]: msft = pd.read_csv("csv/MSFT.csv",
index_col="Date", parse_dates=["Date"])
In [10]: msft.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 8622 entries, 1986-03-13 to 2020-05-27
Data columns (total 6 columns):
# Column Non-Null Count Dtype --- ------ -------------- -----
0 Open 8622 non-null float64
1 High 8622 non-null float64
2 Low 8622 non-null float64
3 Close 8622 non-null float64
4 Adj Close 8622 non-null float64
5 Volume 8622 non-null int64
dtypes: float64(5), int64(1)
memory usage: 471.5 KB

注意,第二次调用 info 输出的内容发生了变化,第二行的内容从 RangeIndex: 8622 entries, 0 to 8621 变为DatetimeIndex: 8622 entries, 1986-03-13 to 2020-05-27。这就表明你现在正在处理一个包含 DatetimeIndex 的 DataFrame。如果想转换其他的数据类型(比如想将 Volume 从 int 转换为 float),你也有两种选择:要么为read_csv 函数提供参数 dtype={“Volume”: float};要么像下面这样使用 astype 方法。
In [11]: msft.loc[:, "Volume"] = msft["Volume"].astype("float")
msft["Volume"].dtype
Out[11]: dtype('float64')

处理时序时,在开始分析之前最好确保索引井然有序:
In [12]: msft = msft.sort_index()
如果只需要访问 DatetimeIndex 的一部分,比如只需要访问日期部分而不需要访问时间, 那么可以像下面这样访问它的 date 属性:
In [13]: msft.index.date
Out[13]: array([datetime.date(1986, 3, 13), datetime.date(1986, 3, 14),
datetime.date(1986, 3, 17), ..., datetime.date(2020, 5, 22),
datetime.date(2020, 5, 26), datetime.date(2020, 5, 27)],
dtype=object)

除了 date,也可以访问 year、month、day 等属性。要在 datetime 类型的列上使用同样的功能,可以使用 dt 属性,比如 df[“column_name”].dt.date。
6.1.2 筛选 DatetimeIndex
如果你的 DataFrame 包含 DatetimeIndex,为 loc 传递 YYYY-MM-DD HH:MM:SS 格式的字符串作为参数可以轻松选取属于特定时间周期的行。pandas 会将这个字符串转换为一个包含整个时间周期的切片。如果要选取属于2019 年的所有行,则需要传递一个字符串,而不是数字:
In [14]: msft.loc["2019", "Adj Close"]
Out[14]: Date
2019-01-02 99.099190
2019-01-03 95.453529
2019-01-04 99.893005
2019-01-07 100.020401
2019-01-08 100.745613
...
2019-12-24 156.515396
2019-12-26 157.798309
2019-12-27 158.086731
2019-12-30 156.724243
2019-12-31 156.833633
Name: Adj Close, Length: 252, dtype: float64
现在更进一步,为2019年6月和2020年5月之间的数据绘制一张图像:
In [15]: msft.loc["2019-06":"2020-05", "Adj Close"].plot()

将鼠标指针悬停在图表上可以显示一条有关该值的提示信息,用鼠标在图上画一个矩形可以进行缩放。双击图表可以返回默认视图。
6.1.3 处理时区
微软在纳斯达克上市,纳斯达克位于纽约,每天下午 4 点闭市。要将这些额外的信息添加到 DataFrame 的索引中,首先要通过 DateOffset 添加闭市时间,然后通过 tz_localize 为时间戳添加正确的时区。由于闭市时间只对收盘价有用,因此需要创建一个新的 DataFrame:
In [16]: # 将时间信息添加到日期中
msft_close = msft.loc[:, ["Adj Close"]].copy()
msft_close.index = msft_close.index + pd.DateOffset(hours=16)
msft_close.head(2)
Out[16]: Adj Close
Date
1986-03-13 16:00:00 0.062205
1986-03-14 16:00:00 0.064427
In [17]: # 令时间戳包含时区信息
msft_close = msft_close.tz_localize("America/New_York")
msft_close.head(2)
Out[17]: Adj Close
Date
1986-03-13 16:00:00-05:00 0.062205
1986-03-14 16:00:00-05:00 0.064427


如果你想将时间戳转换为 UTC 时区,可以使用 DataFrame 的 tz_convert 方法。UTC 代表协调世界时(Coordinated Universal Time),它是格林尼治标准时间(GMT)的后继者。要注意 UTC 闭市时间的变化依赖于纽约夏令时(daylight saving time,DST)的生效情况:
In [18]: msft_close = msft_close.tz_convert("UTC")
msft_close.loc["2020-01-02", "Adj Close"] # 21:00,不启用DST
Out[18]: Date
2020-01-02 21:00:00+00:00 159.737595
Name: Adj Close, dtype: float64
In [19]: msft_close.loc["2020-05-01", "Adj Close"] # 20:00,启用DST
Out[19]: Date
2020-05-01 20:00:00+00:00 174.085175
Name: Adj Close, dtype: float64

像这样处理时序之后可以让你比较位于不同时区的股票交易所的收盘价,即使数据中缺少这样的信息或者数据是以当地时间表示的也没有关系。
6.2 常见时序操作
本节会展示如何执行常见的时序分析任务,比如计算股票收益、绘制各种股票的表现,以 及在热度图中对其收益的相关性进行可视化。本节还会介绍如何更改时序的频率以及如何计算滚动统计信息。
6.2.1 移动和百分比变化率
在金融领域,通常假定股票的对数收益率(log return)服从正态分布。这里的对数收益率指的是当前股价和之前的股价之比的对数。为了体会每日对数收益率的分布情况,我们来绘制一个直方图。不过首先需要算出对数收益率。
Excel 和 Python 中的对数:Excel 用 LN 表示自然对数,用 LOG 表示以 10为底的对数。而在 Python 的 math 模块和 NumPy 中,自然对数用 log 表示,以 10 为底的对数用 log10 表示。
在pandas 中需要使用 shift 方法将值下移一行。这个方法允许你在单行上进行操作,因此可以利用向量化。shift会接受一个正整数或负整数作为参数,如果参数为正,就下移对应行;如果参数为负,则上移对应行。先来看看shift 如何工作:
In [20]: msft_close.head()
Out[20]: Adj Close
Date
1986-03-13 21:00:00+00:00 0.062205
1986-03-14 21:00:00+00:00 0.064427
1986-03-17 21:00:00+00:00 0.065537
1986-03-18 21:00:00+00:00 0.063871
1986-03-19 21:00:00+00:00 0.062760
In [21]: msft_close.shift(1).head()
Out[21]: Adj Close
Date
1986-03-13 21:00:00+00:00 NaN
1986-03-14 21:00:00+00:00 0.062205
1986-03-17 21:00:00+00:00 0.064427
1986-03-18 21:00:00+00:00 0.065537
1986-03-19 21:00:00+00:00 0.063871

现在可以编写易于阅读和理解的基于向量的公式了。NumPy 的 log ufunc 会被应用到每一个元素上以求出其自然对数。然后就可以绘制出这个直方图了。
In [22]: returns = np.log(msft_close / msft_close.shift(1))
returns = returns.rename(columns={"Adj Close": "returns"})
returns.head()
Out[22]: returns
Date
1986-03-13 21:00:00+00:00 NaN
1986-03-14 21:00:00+00:00 0.035097
1986-03-17 21:00:00+00:00 0.017082
1986-03-18 21:00:00+00:00 -0.025749
1986-03-19 21:00:00+00:00 -0.017547
In [23]: # 绘制每日对数收益率的直方图
returns.plot.hist()

要获得简单收益率(simple return),可以使用 pandas 内置的 pct_change 方法。在默认情况 下,它会计算相对于前一行数据的百分比变化率,这也正是简单收益率的定义:
In [24]: simple_rets = msft_close.pct_change()
simple_rets = simple_rets.rename(columns={"Adj Close": "simple rets"})
simple_rets.head()
Out[24]: simple rets
Date
1986-03-13 21:00:00+00:00 NaN
1986-03-14 21:00:00+00:00 0.035721
1986-03-17 21:00:00+00:00 0.017229
1986-03-18 21:00:00+00:00 -0.025421
1986-03-19 21:00:00+00:00 -0.017394

6.2.2 基数的更改和相关性
当处理多个时序时,事情就变得更加有趣了。下面来读取一些其他公司的调整收盘价,这些数据来自亚马逊(AMZN)、谷歌(GOOGL)和 Apple(AAPL),它们也是从雅虎财经上下载下来的:
In [25]: parts = [] # 保存各个DataFrame的列表
for ticker in ["AAPL", "AMZN", "GOOGL", "MSFT"]:
# usecols参数可以让我们只读取Date列和Adj Close列
adj_close = pd.read_csv(f"csv/{ticker}.csv",
index_col="Date", parse_dates=["Date"],
usecols=["Date", "Adj Close"])
# 将列重命名为股票代码
adj_close = adj_close.rename(columns={"Adj Close": ticker})
# 将股价DataFrame添加到parts列表中
parts.append(adj_close)
In [26]: # 将4个DataFrame组合成单个DataFrame
adj_close = pd.concat(parts, axis=1)
adj_close
Out[26]: AAPL AMZN GOOGL MSFT
Date
1980-12-12 0.405683 NaN NaN NaN
1980-12-15 0.384517 NaN NaN NaN
1980-12-16 0.356296 NaN NaN
1980-12-17 0.365115 NaN NaN NaN
1980-12-18 0.375698 NaN NaN NaN
... ... ... ... ...
2020-05-22 318.890015 2436.879883 1413.239990 183.509995
2020-05-26 316.730011 2421.860107 1421.369995 181.570007
2020-05-27 318.109985 2410.389893 1420.280029 181.809998
2020-05-28 318.250000 2401.100098 1418.239990 NaN
2020-05-29 317.940002 2442.370117 1433.520020 NaN
[9950 rows x 4 columns]

见识到 concat 的威力了吗?pandas 将每个时序都沿日期进行了自动对齐。这就是为什么那些历史没有 Apple 悠久的公司股价会包含 NaN。将时序沿日期对齐是一种很典型的操作,但是在 Excel 中很难实现,因而也很容易出错。丢弃所有包含缺失值的行可以保证所有股价都有同样的数据点:
In [27]: adj_close = adj_close.dropna()
adj_close.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 3970 entries, 2004-08-19 to 2020-05-27
Data columns (total 4 columns):
# Column Non-Null Count Dtype --- ------ -------------- -----
0 AAPL 3970 non-null float64
1 AMZN 3970 non-null float64
2 GOOGL 3970 non-null float64
3 MSFT 3970 non-null float64
dtypes: float64(4)
memory usage: 155.1 KB

现在更改股价的基数让所有的时序都从 100 开始。这样做可以在图表中对它们的相关表现进行比较,如图 6-4 所示。要更改时序的基数,首先应将每个值除以起始值,然后再乘以新的基数 100。如果在 Excel 中做过同样的事,那么你一般会写一个结合了绝对值和相对单元格引用的公式,然后把这个公式复制并粘贴到每一行和每一个时序。多亏了向量化和广播技术,在 pandas 中,只需写一个公式即可。
In [28]: # 使用一个从2019年6月到2020年5月的样本
adj_close_sample = adj_close.loc["2019-06":"2020-05", :]
rebased_prices = adj_close_sample / adj_close_sample.iloc[0, :] * 100
rebased_prices.head(2)
Out[28]:
AAPL AMZN GOOGL MSFT
Date
2019-06-03 100.000000 100.000000 100.00000 100.000000
2019-06-04 103.658406 102.178197 101.51626 102.770372
In [29]: rebased_prices.plot()

图 6-4:更改基数后的时序
通过 corr 方法获取数据的相关性,可以了解到不同公司股价之间的独立性。不幸的是,由 于 pandas 没有提供内置的图像类型来将相关性矩阵可视化为热度图,因此需要直接使用 Plotly 的 plotly.express 接口(参见图 6-5)。
In [30]: # 每日对数收益率的相关性
returns = np.log(adj_close / adj_close.shift(1))
returns.corr()
Out[30]:
AAPL AMZN GOOGL MSFT
AAPL 1.000000 0.424910 0.503497 0.486065
AMZN 0.424910 1.000000 0.486690 0.485725
GOOGL 0.503497 0.486690 1.000000 0.525645
MSFT 0.486065 0.485725 0.525645 1.000000
In [31]: import plotly.express as px
In [32]: fig = px.imshow(returns.corr(),
x=adj_close.columns,
y=adj_close.columns,
color_continuous_scale=list(
reversed(px.colors.sequential.RdBu)),
zmin=-1, zmax=1)
fig.show()

执行上面的单元格后出现错误,提示的原因是 np.bool 的别名在当前版本中已经无效了,因此我们需要检查当前环境中的 Numpy 的版本,然后安装指定版本 numpy==1.23.2 以继续使用 np.bool。
print(f"NumPy version: {np.__version__}")
np.__version__
pip install numpy==1.23.2

安装成功后,需要重启内核。直接点击页面中工具栏的 Cell 中的 Run All,即可运行成功:

6.2.3 重新采样
向上/向下采样(up and down sampling)是处理时序时的一项常见任务。向上采样指的是将时序的频率提高,而向下采样指的是将时序的频率降低。例如,在财务概况介绍中,你通常需要展示出月度或季度的状况。要将每日时序转换为每月时序,可以使用 resample 方法,该方法接受字符串形式的频率参数,比如 M 代表日历月底(end-of-calendar-month), BM 代表经营月底(end-of-business-month)。你可以在 pandas 文档中找到所有频率字符串的 列表。和 groupby 类似,可以紧接着调用一个方法指定如何重采样。这里使用了 last 来获得当月最后几个观察值:
In [33]: end_of_month = adj_close.resample("M").last()
end_of_month.head()
Out[33]: AAPL AMZN GOOGL MSFT
Date
2004-08-31 2.132708 38.139999 51.236237 17.673630
2004-09-30 2.396127 40.860001 64.864868 17.900215
2004-10-31 3.240182 34.130001 95.415413 18.107374
2004-11-30 4.146072 39.680000 91.081078 19.344421
2004-12-31 3.982207 44.290001 96.491493 19.279480

除了 last,你也可以选择任何能够在 groupby 上使用的方法,比如 sum 或者 mean。还有一 个 ohlc 方法,它可以方便地获得周期内的开盘价、最高价、最低价和收盘价。这些数据可以用作股价分析中常用的K线图的数据来源。
如果只有月底时序而你想要从中生成每周时序,则必须对时序进行向上采样。asfreq 不会让 pandas 应用任何转换,因此你会看到大部分的值变成了 NaN。如果想向前填充(forward fill)最后的已知值,那么可以使用 ffill 方法:
In [34]: end_of_month.resample("D").asfreq().head() # 无转换
Out[34]: AAPL AMZN GOOGL MSFT
Date
2004-08-31 2.132708 38.139999 51.236237 17.67363
2004-09-01 NaN NaN NaN NaN
2004-09-02 NaN NaN NaN NaN
2004-09-03 NaN NaN NaN NaN
2004-09-04 NaN NaN NaN NaN
In [35]: end_of_month.resample("W-FRI").ffill().head() # 向前填充
Out[35]: AAPL AMZN GOOGL MSFT
Date
2004-09-03 2.132708 38.139999 51.236237 17.673630
2004-09-10 2.132708 38.139999 51.236237 17.673630
2004-09-17 2.132708 38.139999 51.236237 17.673630
2004-09-24 2.132708 38.139999 51.236237 17.673630
2004-10-01 2.396127 40.860001 64.864868 17.900215

向下采样是平滑时序的方式之一,而利用滚动窗口计算统计量是另一种方式,下一节会对此进行介绍。
6.2.4 滚动窗口
在计算时序的统计量时,你通常想要算出滚动统计量,比如移动平均值(moving average)。 移动平均值会关注时序(比如 25 天)的一个子集,先算出这个子集的均值,然后再将窗口向前移动一天。这样就可以产生一个更平滑的新时序,且不容易产生异常值。如果你是做算法交易的,那么你可以关注股价移动平均值发生重叠的地方,然后以此(或是它的某 种变体)作为交易的信号。DataFrame 有一个 rolling 方法,该方法会接受观测数量作为参数。你可以在 rolling 后面链式调用所需的统计量方法——对于移动平均值来说就是在 rolling 后面调用 mean。观察图6-6,你可以轻松地将原本的时序和平滑的移动平均值进行对比。
In [36]: # 用2019年的数据为MSFT绘制移动平均值
msft19 = msft.loc["2019", ["Adj Close"]].copy()
# 将25天的移动平均值作为一个新列添加到DataFrame中
msft19.loc[:, "25day average"] = msft19["Adj Close"].rolling(25).mean()
msft19.plot()

图 6-6:移动平均值的图像
除了 mean,还可以使用很多其他的统计量方法,其中包括 count、sum、median、min、max、 std(标准差)和 var(方差)。
6.3 pandas 的局限性
在你的 DataFrame 不断增长的过程中,应当注意到 DataFrame 的容量上限。Excel 每张工作表能保存的数据有严格的限制,最多只能保存大概 1000000 行 12000 列。而 pandas 只有一个不那么严格的限制:所有的数据必须能够被设备的可用内存装下。如果实际情况并不允许,那么也有一些简单的解决方案:只加载数据集中你需要的部分,或是删去中间结果来释放一些内存。如果还是解决不了内存不足的问题,那么还有不少可以处理大型数据的项目,它们都是 pandas 用户的“老熟人”。构建在 NumPy 和 pandas 之上的 Dask 就是其 中之一,它会将大型数据集分割成多个 pandas DataFrame,将工作负载分布到多个 CPU 核心或多台设备上,从而让你可以处理大型数据集。还有一些其他的大数据项目也可以在某些 DataFrame 中使用,比如 Modin、Koalas、Vaex、PyShark、cuDF、Ibis 和 PyArrow。
更多推荐

所有评论(0)