获取和处理时间序列数据

在上一章中,我们学习了什么是时间序列,并建立了一些标准的符号和术语。现在,让我们从理论转向实践。在本章中,我们将亲自动手,开始处理数据。虽然我们说时间序列数据无处不在,但我们还没有开始处理几个时间序列数据集。我们将开始处理本书中一直使用的数据集,以正确的方式处理它,并学习一些处理缺失值的技术。

在本章中,我们将涵盖以下主题:

技术要求

你需要按照本书序言中的说明设置Anaconda环境,以获得包含本书代码所需所有库和数据集的可用环境。运行笔记本时会安装任何额外的库。

本章代码可在https://github.com/PacktPublishing/Modern-Time-Series-Forecasting-with-Python-2E/tree/main/notebooks/Chapter02..找到。

处理时间序列数据就像处理其他表格数据集一样,只是需要关注时间维度。与任何表格数据集一样,pandas 也完全能够处理时间序列数据。

让我们从零开始动手处理一个数据集。本书将全程使用伦敦智能电表数据集。如果你在环境设置中尚未下载数据,请前往序言并立即下载。

理解时间序列数据集

这是你遇到任何新数据集时的关键第一步,甚至在我们将在第3章《分析与可视化时间序列数据》中介绍的探索性数据分析EDA)之前。理解数据的来源、背后的数据生成过程以及源领域,对于充分理解数据集至关重要。

伦敦数据商店(London Data Store)是一个免费开放的数据共享门户,该数据集由Jean-Michel D收集并丰富,上传至Kaggle。

该数据集包含2011年11月至2014年2月期间参与英国电力网络公司主导的“低碳伦敦”项目的5,567个伦敦家庭的能源消耗读数。读数以半小时为间隔采集。数据集中还包含一些关于家庭的元数据。让我们看看数据集中有哪些元数据:

下表显示了Acorn类别:

Acorn组

Acorn类

富裕成就者

A-奢华生活方式

B-行政财富

C-成熟资金

繁荣上升

D-都市精英

E-职业攀登者

舒适社区

F-乡村社区

G-成功郊区

H-稳定邻里

I-舒适长者

J-初出茅庐

财务紧张

K-学生生活

L-中等收入

M-奋斗家庭

N-贫困退休人员

城市逆境

O-青年困境

P-挣扎社区

Q-困难处境

Kaggle数据集也按天预处理时间序列数据,并合并所有单独的文件。在这里,我们将忽略这些文件,从原始文件开始,这些文件可以在hhblock_dataset文件夹中找到。学习处理原始文件是处理行业真实数据集不可或缺的一部分。

准备数据模型

一旦我们了解了数据的来源,我们就可以查看它,理解不同文件中包含的信息,并构想出如何关联不同文件的思维模型。你可能会认为这很老派,但Microsoft Excel是获得这种初步理解的绝佳工具。如果文件太大无法在Excel中打开,我们也可以在Python中读取,将数据样本保存到Excel文件中,然后打开。但请记住,Excel有时会扰乱数据的格式,尤其是日期,因此我们需要小心,不要保存文件并写回Excel所做的格式更改。如果你对Excel过敏,你也可以在Python中完成,尽管需要更多的按键操作。此练习的目的是查看不同数据文件包含的内容,探索不同文件之间的关系等。

我们可以通过绘制数据模型使其更正式和明确,类似于下图所示:

图2.1 – 伦敦智能电表数据集的数据模型

图2.1:伦敦智能电表数据集的数据模型

数据模型更多的是为了让我们理解数据,而非用于数据工程目的。因此,它只包含最低限度的信息,例如左侧的关键列和右侧的样本数据。我们还有连接不同文件的箭头,并使用了用于链接文件的键。

我们来看几个关键列名及其含义:

每个LCLid都关联一个独特的时间序列。时间序列文件的格式稍有特殊——每一天会在文件的列中以半小时频率包含48个观测值。

笔记本提示

要跟随完整代码,请使用Chapter01文件夹中的01-Pandas_Refresher_&_Missing_Values_Treatment.ipynb笔记本。

在开始处理我们的数据集之前,我们需要建立几个概念。其中之一是pandas DataFrame中的一个概念,它至关重要——pandas的日期时间属性和索引。让我们快速了解几个pandas概念,它们将非常有用。

如果你已经熟悉pandas中的日期时间操作,可以随意跳到下一节。

pandas日期时间操作、索引与切片——快速回顾

我们不用较为复杂的数据集,而是从UCI机器学习库中选取一个简单且格式良好的股票交易所价格数据集,来看看pandas的功能:

# Skipping first row cause it doesn't have any data
df = pd.read_excel("https://archive.ics.uci.edu/ml/machine-learning-databases/00247/data_akbilgic.xlsx", skiprows=1)

我们读取的DataFrame如下所示:

图2.2 – 包含股票交易所价格的DataFrame

图2.2:包含股票交易所价格的DataFrame

现在我们已经读取了DataFrame,让我们开始操作它。

将日期列转换为pd.Timestamp/DatetimeIndex

首先,我们必须将日期列(pandas可能不会自动将其解析为日期)转换为pandas日期时间格式。为此,pandas有一个便捷函数pd.to_datetime。它会自动推断日期时间格式,如果输入是pd.Timestamp,则将其转换为string;如果输入是DatetimeIndex(字符串列表),则转换为list。因此,如果我们传递一个字符串形式的单个日期,pd.to_datetime会将其转换为pd.Timestamp;如果传递一个日期列表,则转换为DatetimeIndex。我们还使用一个便捷函数strftime,它可以将日期表示为我们指定的格式。它使用strftime约定来指定数据格式。例如,%d表示零填充的日期,%B表示月份的完整名称,%Y表示四位数的年份。完整的strftime约定列表可以在https://strftime.org/找到:

>>> pd.to_datetime("13-4-1987").strftime("%d, %B %Y")
'13, April 1987'

现在,我们来看一个自动解析失败的例子。日期是1987年1月4日。让我们看看将字符串传递给函数时会发生什么:

>>> pd.to_datetime("4-1-1987").strftime("%d, %B %Y")
'01, April 1987'

嗯,出乎意料吧?但仔细想想,谁都会犯这样的错误,因为我们没有告诉计算机月份和日期哪个先出现,而pandas默认月份在前。我们来纠正一下:

>>> pd.to_datetime("4-1-1987", dayfirst=True).strftime("%d, %B %Y")
'04, January 1987'

自动日期解析失败的另一种情况是日期字符串采用非标准形式。在这种情况下,我们可以提供一个 strftime 格式化的字符串来帮助 pandas 正确解析日期:

>>> pd.to_datetime("4|1|1987", format="%d|%m|%Y").strftime("%d, %B %Y")
'04, January 1987'

strftime 约定的完整列表可在 https://strftime.org/ 中找到。

从业者提示

由于数据格式多种多样,pandas 可能错误地推断时间。在读取文件时,pandas 会尝试自动解析日期并创建错误。我们可以通过多种方式控制这一行为:使用 parse_dates 标志关闭日期解析,使用 date_parser 参数传入自定义日期解析器,以及使用 year_firstday_first 来轻松指定两种流行的日期格式。从 2.0 版本开始,pandas 支持 date_format,可用于传入日期确切格式的 Python 字典,以列名作为键。

在这些选项中,如果使用 pandas >= 2.0,我倾向于使用 date_format。我们可以保留 parse_dates=True,然后使用 strftime 约定传入确切的日期格式。这确保日期按照我们期望的方式被解析。

如果使用 pandas < 2.0,那么我倾向于在 pd.read_csvpd.read_excel 中都保留 parse_dates=False,以确保 pandas 不会自动解析数据。之后,你可以使用 format 参数转换日期,该参数允许你使用 strftime 约定显式设置列的日期格式。pd.to_datetime 中还有另外两个参数可以使日期推断更少出错——yearfirstdayfirst。如果你不提供明确的日期格式,至少提供其中一个。

现在,让我们将股票价格数据集的日期列转换为 datetime:

df['date'] = pd.to_datetime(df['date'], yearfirst=True)

现在,'date'dtype 应该是 datetime64[ns]&lt;M8[ns],两者都是 pandas/NumPy 原生的 datetime 格式。但为什么我们需要这样做呢?

因为它解锁了广泛的其他功能。传统的 min()max() 函数将开始工作,因为 pandas 知道它是一个 datetime 列:

>>> df.date.min(),df.date.max()
(Timestamp('2009-01-05 00:00:00'), Timestamp('2011-02-22 00:00:00'))

让我们看看 datetime 格式提供的一些很酷的功能。

使用 .dt 访问器和 datetime 属性

由于该列现在是日期格式,所有编码在日期中的语义信息都可以通过 pandas datetime 属性使用。我们可以使用 .dt 访问器访问许多 datetime 属性,例如 monthday_of_weekday_of_year 等:

>>> print(f"""
     Date: {df.date.iloc[0]}
     Day of year: {df.date.dt.day_of_year.iloc[0]}
     Day of week: {df.date.dt.dayofweek.iloc[0]}
     Month: {df.date.dt.month.iloc[0]}
     Month Name: {df.date.dt.month_name().iloc[0]}
     Quarter: {df.date.dt.quarter.iloc[0]}
     Year: {df.date.dt.year.iloc[0]}
     ISO Week: {df.date.dt.isocalendar().week.iloc[0]}
     """)
Date: 2009-01-05 00:00:00
Day of year: 5
Day of week: 0
Month: 1
Month Name: January
Quarter: 1
Year: 2009
ISO Week: 2

从pandas 1.1.0开始,week_of_year已被弃用,因为它在年末/年初产生不一致。取而代之,采用了ISO日历标准(广泛用于政府和商业),我们可以访问ISO日历获取ISO周数。

索引与切片

真正的乐趣在于我们将日期列设为DataFrame的索引。这样,你可以使用pandas支持的所有花哨的切片操作,但作用于日期时间轴。让我们看几个例子。

# Setting the index as the datetime column
df.set_index("date", inplace=True)
# Select all data after 2010-01-04(inclusive)
df["2010-01-04":]
# Select all data between 2010-01-04 and 2010-02-06(exclusive)
df["2010-01-04": "2010-02-06"]
# Select data 2010 and before
df[: "2010"]
# Select data between 2010-01 and 2010-06(both including)
df["2010-01": "2010-06"]

除了语义信息和智能索引与切片,pandas还提供了创建和操作日期序列的工具。

创建日期序列与管理日期偏移

如果你熟悉Python中的range和NumPy中的np.arange,你就会知道它们通过提供起点和终点帮助我们创建integer/float序列。pandas对日期时间也有类似的功能——pd.date_range。该函数接受开始和结束日期,以及频率(每日、每月等),并创建介于之间的日期序列。让我们看看几种创建日期序列的方法。

# Specifying start and end dates with frequency
pd.date_range(start="2018-01-20", end="2018-01-23", freq="D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']
# Specifying start and number of periods to generate in the given frequency
pd.date_range(start="2018-01-20", periods=4, freq="D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']
# Generating a date sequence with every 2 days
pd.date_range(start="2018-01-20", periods=4, freq="2D").astype(str).tolist()
# Output: ['2018-01-20', '2018-01-22', '2018-01-24', '2018-01-26']
# Generating a date sequence every month. By default it starts with Month end
pd.date_range(start="2018-01-20", periods=4, freq="M").astype(str).tolist()
# Output: ['2018-01-31', '2018-02-28', '2018-03-31', '2018-04-30']
# Generating a date sequence every month, but month start
pd.date_range(start="2018-01-20", periods=4, freq="MS").astype(str).tolist()
# Output: ['2018-02-01', '2018-03-01', '2018-04-01', '2018-05-01']

我们还可以使用pd.TimeDelta对日期进行加减天数、月数和其他值的操作:

# Add four days to the date range
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="D")).astype(str).tolist()
# Output: ['2018-01-24', '2018-01-25', '2018-01-26', '2018-01-27']
# Add four weeks to the date range
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="W")).astype(str).tolist()
# Output: ['2018-02-17', '2018-02-18', '2018-02-19', '2018-02-20']

pandas中有很多这样的别名,包括WW-MONMS等。完整列表可以在https://pandas.pydata.org/docs/user_guide/timeseries.html#timeseries-offset-aliases找到。

在本节中,我们了解了在日期时间索引上可以执行的一些有用功能和操作,以及如何操作具有日期时间列的DataFrame。现在,让我们回顾一些处理缺失数据的技术。

处理缺失数据

在处理现实中的大型数据集时,你一定会遇到缺失数据。如果它不属于时间序列,它可能是你收集和映射的额外信息的一部分。在我们贸然用均值填充或删除那些行之前,让我们考虑几个方面:

让我们看一个由澳大利亚堪培拉ACT政府根据CC Attribution 4.0国际许可协议发布的空气质量数据集(https://www.data.act.gov.au/Environment/Air-Quality-Monitoring-Data/94a5-zqnn),并看看如何使用pandas插补这些值(还有更复杂的技术,本章后面都会介绍)。

实践者提示

当使用诸如read_csv的方法读取数据时,pandas提供了几种方便的方式来处理缺失值。pandas默认将诸如#N/Anull等值视为NaN。我们可以通过na_valueskeep_default_na参数控制允许的NaN值列表。

我们选择了Monash地区和PM2.5读数,并人为引入了一些缺失值,如下图所示:

图2.3 – 空气质量数据集中的缺失值

图2.3:空气质量数据集中的缺失值

现在,让我们看看几种可以用来填补缺失值的简单技术:

让我们绘制使用这三种技术得到的插值线:

图2.4 – 使用前向填充、后向填充和均值填充的缺失值插补

图2.4:使用前向填充、后向填充和均值填充的缺失值插补

另一类插补技术涉及插值:

让我们绘制两条插值线:

图2.5 – 使用线性插值和最近邻插值的缺失值插补

图2.5:使用线性插值和最近邻插值的缺失值插补

还有一些非线性插值技术:

让我们绘制这两种非线性插值技术:

图2.6 – 使用样条和多项式插值填充缺失值

图2.6: 使用样条和多项式插值填充缺失值

有关 interpolate 支持的插值技术的完整列表,请访问 https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.Series.interpolate.htmlhttps://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.interp1d.html#scipy.interpolate.interp1d

现在我们更熟悉 pandas 处理日期时间的方式了,让我们回到数据集,将数据转换为更易于管理的形式。

笔记本提示

要跟随预处理完整代码,请使用 Chapter02 文件夹中的 02-Preprocessing_London_Smart_Meter_Dataset.ipynb 笔记本。

将半小时区块级数据 (hhblock) 转换为时间序列数据

在开始处理之前,让我们了解在时间序列数据集中会发现的几类通用信息:

接下来,让我们讨论数据集的格式化。

紧凑、展开和宽格式数据

格式化时间序列数据集有多种方式,尤其是像我们目前这样包含多个相关时间序列的数据集。一种标准做法是使用格式数据。在这种格式中,日期列成为类似索引的东西,每个时间序列占据不同的列。如果有一百万个时间序列,那么就会有一百万零一列(因此得名“宽”)。除了标准的格式数据,我们还可以考虑两种非标准的格式化时间序列数据的方式。尽管没有标准的术语来命名它们,但在本书中我们将称它们为紧凑格式和展开格式。展开格式在某些文献中也称为格式。

紧凑格式数据是指任何特定的时间序列在 pandas DataFrame 中仅占据一行——也就是说,时间维度作为数组被管理在 DataFrame 的行内。时间序列标识符和元数据占据具有标量值的列,而时间序列值和其他时变特征占据具有数组的列。另外包含两列用于推导时间——start_datetimefrequency。如果我们知道时间序列的起始日期时间和频率,就可以轻松构造时间并从 DataFrame 中恢复时间序列。这仅适用于规则采样的时间序列。其优点是 DataFrame 占用更少的内存,且易于和更快地进行操作:

图2.7 – 紧凑格式数据

图2.7:紧凑格式数据

展开格式是指将时间序列沿着 DataFrame 的行展开。如果时间序列有 n 个步长,它在 DataFrame 中占据 n 行。时间序列标识符和元数据沿所有行重复。时变特征也沿行展开。另外,除了起始日期和频率,时间戳作为单独的一列存在:

图2.8 – 展开格式数据

图2.8:展开格式数据

如果说紧凑格式以时间序列标识符作为键,那么展开格式中时间序列标识符和日期时间列将合并成为键。

宽格式数据在传统时间序列文献中更为常见。它可以被视为一种遗留格式,在许多方面具有局限性。还记得我们之前看到的股票数据(图2.2)吗?其中日期作为索引或列之一,不同的时间序列作为 DataFrame 的不同列。随着时间序列数量的增加,它们变得越来越宽,因此得名。这种数据格式不允许我们包含关于时间序列的任何元数据。例如,在我们的数据中,有关于某个家庭是采用标准定价还是动态定价的信息。在宽格式中,我们无法包含这样的元数据。从操作角度来看,宽格式也与关系数据库配合不佳,因为当我们获得新的时间序列时,必须不断向表中添加列。本书中不会使用这种格式。

强制时间序列具有规则间隔

你应该检查并校正的第一件事是,你拥有的规则采样时间序列数据是否具有相等的时间间隔。在实践中,即使是规则采样的时间序列,由于数据收集错误或其他特殊的数据收集方式,中间也可能会缺失一些样本。因此,在处理数据时,我们将确保强制时间序列具有规则间隔。

最佳实践

在处理包含多个时间序列的数据集时,最佳实践是检查所有时间序列的结束日期。如果不统一,我们可以将它们对齐到数据集中所有时间序列的最新日期。

在我们的智能电表数据集中,某些 LCLid 列的结束时间比其他列早得多。可能是该家庭退出了计划,或者他们搬走了,房子空置了;原因可能有很多。然而,在我们强制执行固定间隔时,需要处理这个问题。

我们将在下一节学习如何将数据集转换为时间序列格式。该过程的代码可以在 02-Preprocessing_London_Smart_Meter_Dataset.ipynb 笔记本中找到。

将伦敦智能电表数据集转换为时间序列格式

对于你遇到的每个数据集,将其转换为紧凑形式或扩展形式的步骤会有所不同。这取决于原始数据的结构。在这里,我们将探讨如何转换伦敦智能电表数据集,以便能够将这些经验迁移到其他数据集。

在开始将数据转换为紧凑形式或扩展形式之前,我们需要执行两个步骤:

  1. 查找全局结束日期:我们必须找出所有区块文件中的最大日期,以便知道时间序列的全局结束日期。
  2. 基本预处理:如果你还记得 hhblock_dataset 的结构,你会记得每一行都有一个日期,并且沿着列方向是半小时的区块。我们需要将其重塑为长格式,即每一行都有一个日期和一个半小时区块。这样处理起来更容易。

现在,我们定义独立的函数来将数据转换为紧凑形式和展开形式,并将 apply 这些函数应用于每个 LCLid 列。我们将对每个 LCLid 分别执行此操作,因为每个 LCLid 的开始日期不同。

扩展形式

将数据转换为扩展形式的函数执行以下操作:

  1. 找到起始日期。
  2. 使用起始日期和全局结束日期创建一个标准DataFrame。
  3. LCLid的DataFrame与标准DataFrame进行左合并,缺失数据保留为np.nan
  4. 返回合并后的DataFrame。

一旦我们拥有了所有LCLid的DataFrame,就必须执行几个额外的步骤来完成展开形式的处理:

  1. 将所有DataFrame拼接成一个单独的DataFrame。
  2. 创建一个名为offset的列,它是半小时块的数值表示;例如,hh_33
  3. 通过给日期添加30分钟偏移并删除不必要的列来创建一个时间戳。

对于一个块,这种表示占用约47 MB的内存。

紧凑形式

转换为紧凑形式的函数执行以下操作:

  1. 找到起始日期和时间序列标识符。
  2. 使用起始日期和全局结束日期创建一个标准 DataFrame。
  3. LCLid 的 DataFrame 左合并到标准 DataFrame,缺失数据保留为 np.nan
  4. 按日期对值进行排序。
  5. 返回时间序列数组,以及时间序列标识符、起始日期和时间序列长度。

一旦我们获得每个 LCLid 的这些信息,就可以将其编译成一个 DataFrame,并将频率设置为 30 分钟。

对于一个区块,这种表示形式仅占用约 0.002 MB 内存。

我们将使用紧凑形式,因为它易于处理且资源消耗更少。

映射附加信息

从我们之前准备的数据模型中,我们知道有三个关键文件需要映射:家庭信息天气银行假日

informations_households.csv 文件包含关于家庭的元数据。这些是静态特征,不依赖于时间。为此,我们只需要基于 LCLid(即时间序列标识符)将 informations_households.csv 左合并到紧凑形式。

最佳实践

在执行 pandas merge 时,最常见且意想不到的结果之一是操作前后的行数不一致(即使是在执行左合并)。这通常是因为合并的键中存在重复项。作为最佳实践,可以使用 pandas 合并中的 validate 参数,该参数接受诸如 one_to_onemany_to_one 之类的输入,以便在合并时进行检查,如果假设不满足则会抛出错误。更多信息,请访问 https://pandas.pydata.org/docs/reference/api/pandas.merge.html

另一方面,银行假日和天气是时变特征,应相应处理。需要牢记的最重要的一点是,在映射这些信息时,它们必须与我们已经存储为数组的时间序列完美对齐。

uk_bank_holidays.csv 是一个包含假日日期和假日类型的文件。假日信息在这里非常重要,因为在假日里,家庭成员在家相互陪伴、看电视等,能源消耗模式会有所不同。请按照以下步骤处理此文件:

  1. 将日期列转换为 datetime 格式,并将其设置为 DataFrame 的索引。
  2. 使用我们之前看到的 resample 函数,我们必须确保索引按每 30 分钟重新采样,这是时间序列的频率。
  3. 向前填充一天内的假日,并用 NO_HOLIDAY 填充其余的 NaN 值。

现在,我们已经将假日文件转换为一个 DataFrame,其中每个 30 分钟间隔对应一行。在每一行中,我们有一个列指定该天是否为假日。

weather_hourly_darksky.csv 是一个同样是日频率的文件。我们需要将其降采样到 30 分钟频率,因为我们要映射到的数据是半小时频率的。如果不这样做,天气将只映射到小时时间戳,而半小时时间戳将为空。

处理此文件所需的步骤也与处理假日类似:

  1. 将日期列转换为 datetime 格式,并将其设置为 DataFrame 的索引。
  2. 使用 resample 函数,我们必须确保索引按每 30 分钟重新采样,这是时间序列的频率。
  3. 向前填充天气特征,以填补重采样时产生的缺失值。

现在你已经确保了时间序列与时变特征之间的对齐,你可以遍历每个时间序列,提取天气和银行假日数组,然后将其存储在 DataFrame 的对应行中。

将文件保存到磁盘并加载

完全合并后的DataFrame紧凑形式仅占约10 MB。然而,保存该文件需要一些技巧。如果我们尝试以CSV格式保存该文件,由于我们在pandas列中存储数组的方式(因为数据处于紧凑形式),它将无法正常工作。我们可以将其保存为pickleparquet格式,或任何二进制形式的文件存储。这取决于我们机器上可用RAM的大小。尽管完全合并后的DataFrame只有约10 MB,但以pickle格式保存会导致其大小膨胀至约15 GB。

我们可以做的是将其保存为文本文件,同时进行一些调整以适应列名、列类型和其他元数据,以便将文件读回内存。生成的磁盘文件大小仍然约为15 GB,但由于我们将其作为I/O操作,因此不会将所有数据保留在内存中。我们称之为时间序列(.ts)格式。用于以.ts格式保存紧凑格式、读取.ts格式以及将紧凑格式转换为展开格式的函数可在本书的GitHub仓库的src/data_utils.py目录下找到。

如果你不需要将所有DataFrame存储在一个文件中,你可以将其分割成多个块,并以二进制格式(如parquet)单独保存。对于我们的数据集,我们按照这个路线,将整个DataFrame分成块并保存为parquet文件。这是最适合我们的路线,原因如下:

对于非常大的数据集,我们可以使用一些pandas的替代方案,这使得处理内存不足的数据集更加容易。Polars是一个很好的库,具有惰性加载且速度非常快。对于真正巨大的数据集,使用带有分布式集群的PySpark可能是正确的选择。

现在我们已经处理了数据集并将其存储在磁盘上,让我们将其读回内存,并探讨处理缺失数据的更多技术。

处理长时间段的缺失数据

我们之前看到了一些处理缺失数据的技术——前向和后向填充、插值等。如果只有一两个缺失数据点,这些技术通常有效。但如果缺失一大段数据,这些简单技术就不够用了。

笔记本提示

要跟随缺失数据插补的完整代码,请使用Chapter02文件夹中的03-Handling_Missing_Data_(Long_Gaps).ipynb笔记本。

让我们从内存中读取块0–7 parquet

block_df = pd.read_parquet("data/london_smart_meters/preprocessed/london_smart_meters_merged_block_0-7.parquet")

我们保存的数据是紧凑形式的。我们需要将其转换为展开形式,因为在这种形式下处理时间序列数据更容易。由于我们只需要时间序列的一个子集(为了更快地演示),我们将从这七个块中提取一个块。要将紧凑形式转换为展开形式,我们可以使用src/utils/data_utils.py中一个名为compact_to_expanded的有用函数:

#Converting to expanded form
exp_block_df = compact_to_expanded(block_df[block_df.file=="block_7"], timeseries_col = 'energy_consumption',
static_cols = ["frequency", "series_length", "stdorToU", "Acorn", "Acorn_grouped", "file"],
time_varying_cols = ['holidays', 'visibility', 'windBearing', 'temperature', 'dewPoint',
       'pressure', 'apparentTemperature', 'windSpeed', 'precipType', 'icon',
       'humidity', 'summary'],
ts_identifier = "LCLid")

可视化一组相关时间序列中缺失数据的最佳方法之一是使用一个非常有用的包,名为missingno::

# Pivot the data to set the index as the datetime and the different time series along the columns
plot_df = pd.pivot_table(exp_block_df, index="timestamp", columns="LCLid", values="energy_consumption")
# Generate Plot. Since we have a datetime index, we can mention the frequency to decide what do we want on the X axis
msno.matrix(plot_df, freq="M")

上述代码产生以下输出:

图2.9 – 块7中缺失数据的可视化

图2.9:块7中缺失数据的可视化

仅当相关时间序列少于25个时,才尝试missingno可视化。如果你的数据集包含数千个时间序列(例如我们的完整数据集),应用此可视化将导致无法辨认的图表和冻结的计算机。

这个可视化让我们一眼就能看出很多信息。Y轴包含我们绘制可视化的日期,而X轴包含列,即不同的家庭。我们知道所有时间序列并非完全对齐——也就是说,并非所有序列都在同一时间开始和结束。在许多时间序列开头看到的大片白色空隙表明这些消费者的数据收集开始得比其他消费者晚。我们还可以看到少数时间序列比其他序列结束得早,这意味着要么他们不再是消费者,要么测量阶段停止了。还有许多时间序列中的一些较小的白色线条,这些是真正的缺失值。我们还可以注意到右侧有一个迷你图,它紧凑地表示了每行缺失列的数量。如果没有缺失值(所有时间序列都有值),那么迷你图将在最右侧。最后,如果有很多缺失值,线条将偏向左。

仅仅因为有缺失值,我们并不打算填充/插补它们,因为是否插补缺失数据的决定在后面的工作流程中才会做出。对于某些模型,我们不需要进行插补,而对于其他模型则需要。有多种插补缺失数据的方法,选择哪一种是我们事先无法做出的另一个决定。

所以现在,让我们选择一个LCLid并深入挖掘。我们已经知道在2012-09-302012-10-31之间存在一些缺失值。让我们可视化那个时期:

# Taking a single time series from the block
ts_df = exp_block_df[exp_block_df.LCLid=="MAC000193"].set_index("timestamp")
msno.matrix(ts_df["2012-09-30": "2012-10-31"], freq="D")

上述代码产生以下输出:

图2.10 – 2012-09-30至2012-10-31期间MAC000193缺失数据可视化

图2.10:2012-09-30至2012-10-31期间MAC000193缺失数据可视化

这里,我们看到缺失数据位于 2012-10-182012-10-19 之间。通常,我们会直接对该时期内的缺失数据进行插补,但由于我们是从学术角度审视这个问题,因此将采取略有不同的方式。

让我们引入一个人工缺失数据部分,看看我们将要研究的各种技术如何插补缺失数据,并计算一个指标来衡量我们与真实时间序列的接近程度(我们将使用一个名为 平均绝对误差MAE)的指标来进行比较,它不过是时间步上绝对误差的平均值。只需理解这是一个越低越好的指标,我们将在本书后面详细讨论:

# The dates between which we are nulling out the time series
window = slice("2012-10-07", "2012-10-08")
# Creating a new column and artificially creating missing values
ts_df['energy_consumption_missing'] = ts_df.energy_consumption
ts_df.loc[window, "energy_consumption_missing"] = np.nan

现在,让我们在时间序列中绘制缺失区域:

图2.11 – 2012-10-05至2012-10-10期间MAC000193的能耗

图2.11:2012-10-05至2012-10-10期间MAC000193的能耗

我们缺失了整整两天的能耗读数,这意味着有96个缺失数据点(每半小时一个)。如果我们使用之前看到的某种技术,如插值,我们会发现结果基本是一条直线,因为没有一种方法足够复杂以捕捉长时间段内的模式。

有几种技术可用于填充数据中的此类大段缺失。我们现在将介绍这些技术。

用前一天的数据插补

由于这是一个半小时粒度的能耗时间序列,因此有理由认为可能存在日复一日重复的模式。从上午9:00到上午10:00的能耗可能更高,因为大家都在准备去办公室,而白天大部分房屋可能空置,能耗则会下降。

因此,填充缺失数据最简单的方法是使用前一天的能耗读数,这样2012-10-18上午10:00的能耗读数就可以用2012-10-17上午10:00的能耗读数来填充:

#Shifting 48 steps to get previous day
ts_df["prev_day"] = ts_df['energy_consumption'].shift(48)
#Using the shifted column to fill missing
ts_df['prev_day_imputed'] =  ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"prev_day_imputed"] = ts_df.loc[null_mask,"prev_day"]
mae = mean_absolute_error(ts_df.loc[window, "prev_day_imputed"], ts_df.loc[window, "energy_consumption"])

让我们看看填补后的效果:

图2.12 – 用前一天的数据填补

图2.12:用前一天的数据填补

虽然看起来更好,但这种方法也非常脆弱。当我们复制前一天的数据时,我们假设任何变异或异常行为也会重复。我们已经可以看到,前一天和后一天的模式并不相同。

小时平均曲线

更好的方法是根据数据计算小时曲线——每个小时的平均消耗量——并用平均值来填补缺失数据:

#Create a column with the Hour from timestamp
ts_df["hour"] = ts_df.index.hour
#Calculate hourly average consumption
hourly_profile = ts_df.groupby(['hour'])['energy_consumption'].mean().reset_index()
hourly_profile.rename(columns={"energy_consumption": "hourly_profile"}, inplace=True)
#Saving the index because it gets lost in merge
idx = ts_df.index
#Merge the hourly profile dataframe to ts dataframe
ts_df = ts_df.merge(hourly_profile, on=['hour'], how='left', validate="many_to_one")
ts_df.index = idx
#Using the hourly profile to fill missing
ts_df['hourly_profile_imputed'] = ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"hourly_profile_imputed"] = ts_df.loc[null_mask,"hourly_profile"]
mae = mean_absolute_error(ts_df.loc[window, "hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"])

让我们看看这是否更好:

图2.13 – 使用小时曲线填补

图2.13:使用小时曲线填补

这为我们提供了一个更通用的曲线,没有我们在单个日子里看到的尖峰。逐小时的波动也已经按照预期被捕捉到。MAE也比之前更低。

每个工作日的平均小时曲线

我们可以通过引入每个工作日的特定曲线来进一步优化这条规则。按理说,工作日的用电模式不会和周末相同。因此,我们可以分别计算每个工作日的平均小时消耗量,这样我们就有周一、周二等的曲线:

#Create a column with the weekday from timestamp
ts_df["weekday"] = ts_df.index.weekday
#Calculate weekday-hourly average consumption
day_hourly_profile = ts_df.groupby(['weekday','hour'])['energy_consumption'].mean().reset_index()
day_hourly_profile.rename(columns={"energy_consumption": "day_hourly_profile"}, inplace=True)
#Saving the index because it gets lost in merge
idx = ts_df.index
#Merge the day-hourly profile dataframe to ts dataframe
ts_df = ts_df.merge(day_hourly_profile, on=['weekday', 'hour'], how='left', validate="many_to_one")
ts_df.index = idx
#Using the day-hourly profile to fill missing
ts_df['day_hourly_profile_imputed'] = ts_df['energy_consumption_missing']
ts_df.loc[null_mask,"day_hourly_profile_imputed"] = ts_df.loc[null_mask,"day_hourly_profile"]
mae = mean_absolute_error(ts_df.loc[window, "day_hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"])

我们来看看这是什么样子:

图2.14 – 对每个工作日的小时均值进行插补

这看起来与另一张图非常相似,但这是因为我们插补的那天是工作日,而工作日的日分布曲线相似。平均绝对误差也比日分布曲线低。周末分布曲线略有不同,你可以在附带的Jupyter笔记本中看到。

季节性插值

尽管计算季节性分布曲线并用它们进行插补效果很好,但在某些情况下,尤其是时间序列中存在趋势时,这种简单的方法就会不足。简单的季节性分布曲线根本无法捕捉趋势,完全忽略了它。对于这种情况,我们可以执行以下操作:

  1. 计算季节性分布曲线,类似于我们之前计算平均值的方法。
  2. 减去季节性分布曲线,并应用我们之前看到的任何一种插值技术。
  3. 将季节性分布曲线加回到插值后的序列中。

这个过程已在本书GitHub仓库的src/imputation/interpolation.py文件中实现。我们可以如下使用:

from src.imputation.interpolation import SeasonalInterpolation
# Seasonal interpolation using 48*7 as the seasonal period.
recovered_matrix_seas_interp_weekday_half_hour = SeasonalInterpolation(seasonal_period=48*7,decomposition_strategy="additive", interpolation_strategy="spline", interpolation_args={"order":3}, min_value=0).fit_transform(ts_df.energy_consumption_missing.values.reshape(-1,1))
ts_df['seas_interp_weekday_half_hour_imputed'] = recovered_matrix_seas_interp_weekday_half_hour

这里的关键参数是seasonal_period,它告诉算法寻找每seasonal_period重复的模式。如果我们指定seasonal_period=48,它将寻找每48个数据点重复的模式。在我们的案例中,它们是每天重复(因为一天有48个半小时时间步长)。除此之外,我们还需要指定执行哪种插值。

附加信息

在内部,我们使用了称为季节性分解(statsmodels.tsa.seasonal.seasonal_decompose)的方法,该方法将在第3章分析与可视化时间序列数据》中介绍,以隔离季节性成分。

这里,我们使用48(半小时)和48*7(工作日到半小时)进行了季节性插值,并绘制了最终的插值结果:

图2.15 – 使用季节性插值进行填充

图2.15:使用季节性插值进行填充

这里,我们可以看到两者都捕捉到了季节性模式,但每个工作日的半小时剖面更好地捕捉了第一天的峰值,因此它们的 MAE 更低。在小时平均值方面没有改进,主要是因为时间序列中没有明显的上升或下降趋势。

至此,本章内容结束。我们现在正式进入了处理、清洗和加工时间序列数据的细节工作。恭喜你完成了本章!

小结

在本章中,在简要回顾了pandas DataFrame(特别是日期时间操作和处理缺失数据的简单技术)之后,我们学习了存储和处理时间序列数据的两种形式——紧凑形式和扩展形式。利用这些知识,我们获取原始数据集并构建了一个管道,将其转换为紧凑形式。如果你运行了随附的笔记本,应该已将预处理后的数据集保存在磁盘上。我们还深入探讨了一些处理长时间缺失数据的技术。

现在我们已经有了处理后的数据集,在下一章中,我们将学习如何可视化和分析时间序列数据集。