Python现代时间序列预测——获取与处理时间序列数据

364 阅读39分钟

在上一章中,我们了解了什么是时间序列,并建立了一些标准的符号和术语体系。现在,让我们从理论转向实践。在本章中,我们将亲自动手开始处理数据。尽管我们已经提到时间序列数据无处不在,但我们尚未真正着手处理任何时间序列数据集。本章中,我们将开始操作贯穿全书的数据集,以正确的方式对其进行处理,并学习一些处理缺失值的技术。

本章将涵盖以下主题:

  • 理解时间序列数据集
  • pandas 中的日期时间操作、索引与切片——快速回顾
  • 处理缺失数据
  • 映射附加信息
  • 将文件保存到磁盘和从磁盘加载文件
  • 处理长时间段的缺失数据

技术要求

您需要根据本书前言中的说明,设置 Anaconda 环境,以获得包含本书代码所需的所有库和数据集的工作环境。任何额外的库都将在运行笔记本时安装。
本章的代码可以在以下链接找到:github.com/PacktPublis…

处理时间序列数据类似于处理其他表格数据,只是需要更多地关注时间维度。与其他表格数据一样,pandas 也能够很好地处理时间序列数据。

现在让我们开始亲自动手,从头开始处理一个数据集。本书将使用伦敦智能电表(London Smart Meters)数据集贯穿整个学习过程。如果您尚未在环境设置过程中下载数据,请现在前往前言部分完成此操作。

理解时间序列数据集

在接触任何新数据集时,这都是关键的第一步,甚至要优先于探索性数据分析(EDA)。探索性数据分析将在第 3 章《分析与可视化时间序列数据》中详细介绍。了解数据的来源、数据生成过程及其所属领域,对于深入理解数据集至关重要。

本数据集由伦敦数据存储(London Data Store),一个免费且开放的数据共享门户提供。数据集由 Jean-Michel D 收集和丰富后上传至 Kaggle。

数据集包含了参与由英国电力网络(UK Power Networks)主导的低碳伦敦(Low Carbon London)项目的 5,567 个伦敦家庭的能源消耗读数。数据收集时间为 2011 年 11 月至 2014 年 2 月,读数间隔为每半小时一次。此外,数据集还包含了一些与这些家庭相关的元数据。以下是数据集中提供的元数据内容:

  • Acorn 分类
    CACI UK 将英国人口分为不同的人口类型,称为 Acorn。对于数据中的每个家庭,都提供了相应的 Acorn 分类。这些分类(如 Lavish Lifestyles、City Sophisticates、Student Life 等)被归类为父类(如 Affluent Achievers、Rising Prosperity、Financially Stretched 等)。Acorn 分类的完整列表见表 2.1,每个类别的详细文档可从 Acorn 用户指南 获取。
  • 动态时段电价与固定费率
    数据集包含了两组用户——一组在 2013 年期间使用动态时段电价(dToU),另一组使用固定费率电价。动态电价(dToU)的价格通过智能电表显示屏(IHD)或短信提前一天告知用户。
  • 附加数据
    Jean-Michel D 对数据集进行了丰富,添加了天气数据和英国的银行假期数据。

以下表格展示了 Acorn 分类:

Acorn GroupAcorn Class
Affluent AchieversA-Lavish Lifestyles
B-Executive Wealth
C-Mature Money
Rising ProsperityD-City Sophisticates
E-Career Climbers
Comfortable CommunitiesF-Countryside Communities
G-Successful Suburbs
H-Steady Neighborhoods
I-Comfortable Seniors
J-Starting Out
Financially StretchedK-Student Life
L-Modest Means
M-Striving Families
N-Poorer Pensioners
Urban AdversityO-Young Hardship
P-Struggling Estates
Q-Difficult Circumstances

表 2.1:Acorn 分类

此外,Kaggle 上的数据集对时间序列数据进行了预处理,按天整理并合并了所有独立文件。但在这里,我们将忽略这些预处理文件,直接从原始文件开始,这些文件位于 hhblock_dataset 文件夹中。在工业界中,学习如何处理原始文件是处理真实数据集的重要部分。

准备数据模型

在了解数据来源后,我们可以开始查看数据,理解不同文件中包含的信息,并建立一个如何关联这些文件的心智模型。这听起来可能有些老派,但 Microsoft Excel 是一个非常好的工具,可以帮助我们实现这一初步理解。如果文件过大,无法直接用 Excel 打开,我们也可以用 Python 读取文件,将一部分数据保存为 Excel 文件,再打开查看。然而需要注意的是,Excel 有时会改变数据的格式,尤其是日期格式,因此我们必须确保不将文件保存并写回这些格式更改。如果您对 Excel 不感兴趣,也可以完全用 Python 实现,尽管这样可能需要更多的操作步骤。

此步骤的目的在于了解不同数据文件包含的内容,探索它们之间的关系等。我们可以通过绘制数据模型将这一过程形式化和显性化,例如以下图所示的数据模型:

image.png

数据模型主要用于帮助我们理解数据,而非用于任何数据工程目的。因此,它仅包含最基本的信息,例如左侧的关键列和右侧的示例数据。我们还会使用箭头连接不同文件,指示用于关联文件的关键字段。

以下是一些关键列名及其含义:

  • LCLid: 每个家庭的唯一消费者 ID
  • stdorTou: 该家庭使用动态时段电价(dToU)还是标准费率
  • Acorn: ACORN 分类
  • Acorn_grouped: ACORN 分组
  • file: 数据块编号

每个 LCLid 都有一个独立的时间序列与之关联。时间序列文件的格式稍显复杂——每天的数据以文件列的形式存储,每天共有 48 个以半小时为频率的观测值。

笔记本提醒
要查看完整代码,请使用位于 Chapter01 文件夹中的 01-Pandas_Refresher_&_Missing_Values_Treatment.ipynb 笔记本文件。

在开始操作数据集之前,我们需要明确一些概念。其中之一是 pandas 数据框(DataFrame)中的一个重要概念——pandas 的日期时间属性和索引。让我们快速回顾几个与 pandas 相关的重要概念,这些概念将在后续分析中非常有用。

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

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

为了简化理解,我们不使用略显复杂的数据集,而是选择一个来自 UCI Machine Learning Repository 的简单、格式良好的股票价格数据集,来展示 pandas 的功能:

# 跳过第一行,因为其中没有数据
df = pd.read_excel("https://archive.ics.uci.edu/ml/machine-learning-databases/00247/data_akbilgic.xlsx", skiprows=1)

我们读取的 DataFrame 如下所示:

image.png

现在我们已经读取了 DataFrame,接下来开始对其进行操作。

将日期列转换为 pd.TimestampDatetimeIndex

首先,我们需要将日期列(可能并未被 pandas 自动识别为日期格式)转换为 pandas 的日期时间格式。为此,pandas 提供了一个非常方便的函数 pd.to_datetime
该函数会自动推断日期时间的格式,并根据输入类型完成相应的转换:

  • 如果输入为字符串,则将其转换为 pd.Timestamp
  • 如果输入为字符串列表,则将其转换为 DatetimeIndex

此外,我们还可以使用另一个实用函数 strftime 来将日期格式化为指定的格式。strftime 使用标准的日期格式化约定,例如:

  • %d 表示零填充的日期
  • %B 表示月份的全名
  • %Y 表示四位数年份

完整的 strftime 格式约定列表可以参考 strftime 官方文档

示例:

>>> pd.to_datetime("13-4-1987").strftime("%d, %B %Y")
'13, April 1987'
自动解析失败的情况

当自动解析失败时,可以通过调整参数来解决。以下是两个常见的场景:

  1. 未指定日期顺序
    当日期字符串为 4-1-1987(表示 1987 年 1 月 4 日)时,默认解析为 April 1, 1987
>>> pd.to_datetime("4-1-1987").strftime("%d, %B %Y")
'01, April 1987'

通过设置 dayfirst=True 修正解析顺序:

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

2. 非标准日期格式
对于类似 4|1|1987 的非标准格式,可以通过提供 strftime 格式来正确解析:

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

由于数据格式的多样性,pandas 有时会错误地推断时间格式。在读取文件时,pandas 会尝试自动解析日期,但可能会引发错误。以下是一些控制该行为的方法:

  • 使用 parse_dates 参数关闭日期解析:parse_dates=False
  • 使用 date_parser 参数提供自定义的日期解析函数
  • 使用 yearfirstdayfirst 参数表示常见的日期格式
  • 自 pandas 2.0 起,可以使用 date_format 参数作为字典传入日期格式,键为列名,值为日期格式字符串

推荐用法:

  • 如果使用 pandas >= 2.0,建议使用 date_format 配合 parse_dates=True,以确保日期按照指定的格式解析。
  • 如果使用 pandas < 2.0,建议将 parse_dates=False,并通过 pd.to_datetimeformat 参数明确设置日期格式。

转换股票价格数据集中的日期列

将日期列转换为 pandas 日期时间格式:

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

转换后,'date' 列的 dtype 应为 datetime64[ns]<M8[ns],这是 pandas 和 NumPy 原生的日期时间格式。

转换的优势

转换为日期时间格式后,将解锁许多额外功能。例如:

  • 使用传统方法进行日期范围的计算
>>> df.date.min(), df.date.max()
(Timestamp('2009-01-05 00:00:00'), Timestamp('2011-02-22 00:00:00'))

接下来,让我们探索一些日期时间格式的强大功能!

使用 .dt 访问器与日期时间属性

现在日期列已经转换为日期时间格式,我们可以通过 pandas 的日期时间属性访问日期中编码的语义信息。通过 .dt 访问器,我们可以获取许多日期时间属性,例如 month(月份)、day_of_week(星期几)、day_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 已被弃用,因为它在年末/年初处理上存在不一致问题。pandas 采用了广泛使用的 ISO 日历标准(在政府和商业领域常见),可以通过 isocalendar() 获取 ISO 周。


索引与切片操作

当将日期列设置为 DataFrame 的索引时,可以对日期时间轴进行丰富的切片操作,这使得操作变得更加直观和高效。例如:

# 将日期列设置为索引
df.set_index("date", inplace=True)

# 选择 2010-01-04(包含)之后的所有数据
df["2010-01-04":]

# 选择 2010-01-04 至 2010-02-06(不包含)的数据
df["2010-01-04": "2010-02-06"]

# 选择 2010 年及之前的数据
df[: "2010"]

# 选择 2010 年 1 月到 2010 年 6 月(均包含)的数据
df["2010-01": "2010-06"]

这种索引和切片操作极大地简化了日期时间数据的处理。


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

类似于 Python 的 range 和 NumPy 的 np.arange,pandas 提供了 pd.date_range 来生成日期序列。该函数支持指定开始和结束日期,以及频率(如每日、每月等),以生成日期序列。例如:

# 指定开始和结束日期,频率为每日
pd.date_range(start="2018-01-20", end="2018-01-23", freq="D").astype(str).tolist()
# 输出:['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']

# 指定开始日期和生成的周期数
pd.date_range(start="2018-01-20", periods=4, freq="D").astype(str).tolist()
# 输出:['2018-01-20', '2018-01-21', '2018-01-22', '2018-01-23']

# 每 2 天生成一次日期序列
pd.date_range(start="2018-01-20", periods=4, freq="2D").astype(str).tolist()
# 输出:['2018-01-20', '2018-01-22', '2018-01-24', '2018-01-26']

# 每月生成一次日期序列(默认月末)
pd.date_range(start="2018-01-20", periods=4, freq="M").astype(str).tolist()
# 输出:['2018-01-31', '2018-02-28', '2018-03-31', '2018-04-30']

# 每月生成一次日期序列(月初)
pd.date_range(start="2018-01-20", periods=4, freq="MS").astype(str).tolist()
# 输出:['2018-02-01', '2018-03-01', '2018-04-01', '2018-05-01']
日期偏移的加减运算

使用 pd.Timedelta 可以对日期进行加减操作。例如:

# 将日期范围加上 4 天
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="D")).astype(str).tolist()
# 输出:['2018-01-24', '2018-01-25', '2018-01-26', '2018-01-27']

# 将日期范围加上 4 周
(pd.date_range(start="2018-01-20", end="2018-01-23", freq="D") + pd.Timedelta(4, unit="W")).astype(str).tolist()
# 输出:['2018-02-17', '2018-02-18', '2018-02-19', '2018-02-20']

pandas 支持丰富的时间偏移别名(如 WW-MONMS 等)。完整列表可以参考 pandas 时间序列文档


通过这一节的学习,我们了解了如何利用日期时间索引和属性操作 DataFrame,并掌握了日期序列的生成和管理方法。接下来,我们将学习一些处理缺失数据的技术。

处理缺失数据

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

首先要考虑的是我们担心的缺失数据是否确实缺失。为此,我们需要考虑数据生成过程(DGP)(生成时间序列的过程)。举个例子,假设我们来看一家本地超市的销售情况。您获得了过去两年的销售点(POS)交易数据,并将其处理成时间序列。在分析数据时,您发现有些产品在几天内没有任何交易。现在,您需要思考的是,这些缺失的数据是真的缺失,还是这种缺失性本身提供了一些信息。如果某个产品在某一天没有任何交易,在处理时它会显示为缺失数据,尽管实际上并未缺失。这告诉我们,该商品当天没有销售,您应该用零来填充这些缺失的数据。

那么,如果您发现每个星期天的数据都缺失——也就是说,缺失有一定的模式——该怎么办呢?这会变得棘手,因为如何填补这些空白取决于您打算使用的模型。如果您用零填补这些空白,一个依赖于最近数据来预测未来的模型可能会受到影响,尤其是在预测星期一时。然而,如果您告知模型前一天是星期天,那么模型仍然可以学会区分这种情况。

最后,如果您看到一个通常畅销的产品出现零销售,这可能是由于POS机故障、数据录入错误或缺货等原因造成的。这类缺失值可以通过一些技术进行插补。

让我们来看一个由澳大利亚堪培拉ACT政府发布的空气质量数据集(www.data.act.gov.au/Environment…),并看看如何使用pandas对这些值进行插补(还有更复杂的技术,稍后本章将全部涵盖)。

从业者提示: 在使用诸如read_csv的方法读取数据时,pandas提供了一些方便的处理缺失值的方式。pandas默认将#N/A、null等值视为NaN。我们可以使用na_values和keep_default_na参数来控制允许的NaN值列表。

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

image.png

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

上一个观测值向前填充(Last Observation Carried Forward)或前向填充(Forward Fill) :这种插补技术使用最后一个观测值来填补所有缺失值,直到找到下一个观测值。这也称为前向填充。我们可以这样做:

df['pm2_5_1_hr'].ffill()

下一个观测值向后填充(Next Observation Carried Backward)或后向填充(Backward Fill) :这种插补技术使用下一个观测值,向后填补所有缺失值。这也称为后向填充。让我们看看如何在 pandas 中做到这一点:

df['pm2_5_1_hr'].bfill()

均值填充(Mean Value Fill) :这种插补技术也相当简单。我们计算整个序列的均值,在发现缺失值的地方,用均值填充:

df['pm2_5_1_hr'].fillna(df['pm2_5_1_hr'].mean())

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

image.png

另一类插补技术涵盖了插值(Interpolation):

线性插值(Linear Interpolation) :线性插值就像在两个观测点之间画一条直线,并填补缺失值,使其位于这条线上。我们可以这样做:

df['pm2_5_1_hr'].interpolate(method="linear")

最近邻插值(Nearest Interpolation) :直观上,这种方法类似于前向填充和后向填充的组合。对于每个缺失值,找到最近的观测值并用其填补缺失值:

df['pm2_5_1_hr'].interpolate(method="nearest")

让我们绘制这两种插值方法得到的插补线:

image.png

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

样条插值(Spline)、多项式插值(Polynomial)及其他插值方法:除了线性插值,pandas 还支持调用后端 SciPy 例程的非线性插值技术。样条插值和多项式插值类似。它们为数据拟合给定阶数的样条/多项式,并使用该拟合结果填补缺失值。在使用 interpolate 方法中的样条或多项式时,我们应始终提供阶数(order)。阶数越高,用于拟合观测点的函数就越灵活。让我们看看如何使用样条插值和多项式插值:

df['pm2_5_1_hr'].interpolate(method="spline", order=2)
df['pm2_5_1_hr'].interpolate(method="polynomial", order=5)

让我们绘制这两种非线性插值方法得到的插补线:

image.png

为获取 interpolate 支持的插值技术的完整列表,请访问 pandas.pydata.org/pandas-docs…docs.scipy.org/doc/scipy/r…

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

笔记本提示:

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

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

在开始处理之前,让我们先了解时间序列数据集中我们将会遇到的一些信息的通用类别:

时间序列标识符(Time Series Identifiers) :这些是用于标识特定时间序列的标识符。它可以是名称、ID或任何其他唯一特征——例如,SKU名称、零售销售数据集的ID,或我们正在处理的能源数据集中的消费者ID,都是时间序列标识符。

元数据或静态特征(Metadata or Static Features) :这些信息不会随时间变化。一个例子是我们数据集中家庭的ACORN分类。

时变特征(Time-Varying Features) :这些信息会随时间变化——例如,天气信息。对于每一个时间点,我们都有不同的天气值,这与ACORN分类不同。

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

紧凑型、扩展型和宽型数据(Compact, Expanded, and Wide Forms of Data)

时间序列数据集的格式化方式有很多,尤其是像我们现在拥有的那样包含许多相关时间序列的数据集。标准的格式化方式是宽型数据(wide data)。在这种格式中,日期列成为一种索引,每个时间序列占据不同的列。如果有一百万个时间序列,那么数据集将有一百万零一列(因此称为宽型)。除了标准的宽型数据,我们还可以查看两种非标准的时间序列数据格式化方式。虽然它们没有标准的命名法,但在本书中我们将其称为紧凑型和扩展型。扩展型在一些文献中也被称为长型(long)。

紧凑型数据(Compact-form Data) 是指任何特定的时间序列在pandas DataFrame中只占据一行——也就是说,时间维度在DataFrame的行内作为一个数组进行管理。时间序列标识符和元数据占据具有标量值的列,然后是时间序列值;其他时变特征占据具有数组的列。还包括两个额外的列来推断时间——start_datetimefrequency。如果我们知道时间序列的起始日期时间和频率,就可以轻松构建时间并从DataFrame中恢复时间序列。这仅适用于规则采样的时间序列。其优点是DataFrame占用的内存更少,并且易于且更快速地进行处理:

image.png

扩展型数据(Expanded Form) 是指时间序列在DataFrame的行上展开。如果时间序列有n个时间步,它将在DataFrame中占据n行。时间序列标识符和元数据会在所有行中重复。时变特征也会在行上展开。此外,数据集中不再使用起始日期和频率,而是将时间戳作为一个单独的列:

image.png

如果紧凑型数据以时间序列标识符作为键,那么时间序列标识符和日期时间列将被合并并成为键。

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

在时间序列中强制执行规则间隔

您首先需要检查和纠正的一件事是,您拥有的规则采样时间序列数据是否具有相等的时间间隔。实际上,即使是规则采样的时间序列也可能由于数据收集错误或其他特殊的数据收集方式而缺失一些样本。因此,在处理数据时,我们将确保在时间序列中强制执行规则间隔。

最佳实践:

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

在我们的智能电表数据集中,某些 LCLid 列的结束时间比其他列早得多。可能是因为家庭选择退出了该项目,或者他们搬出了并将房屋空置;原因可能有很多。然而,在强制执行规则间隔时,我们需要处理这种情况。

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

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

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

在开始将数据处理为紧凑型或扩展型之前,我们需要完成两个步骤:

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

现在,让我们定义将数据转换为紧凑型和扩展型的独立函数,并将这些函数应用于每个 LCLid 列。我们将分别对每个 LCLid 进行操作,因为每个 LCLid 的起始日期不同。

扩展型

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

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

一旦我们拥有所有 LCLid 的 DataFrame,我们必须执行几个额外的步骤以完成扩展型处理:

  1. 将所有 DataFrame 连接成一个单一的 DataFrame。
  2. 创建一个名为 offset 的列,它是半小时区块的数值表示;例如,hh_3 → 3。
  3. 通过向日期添加30分钟的偏移量来创建时间戳,并删除不必要的列。
  4. 对于一个区块,这种表示方式占用约47 MB的内存。

紧凑型

将数据转换为紧凑型的函数执行以下操作:

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

一旦我们为每个 LCLid 获得了这些信息,我们可以将其编译成一个 DataFrame 并添加30分钟作为频率。

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

我们将使用紧凑型,因为它易于操作且资源消耗更少。

映射附加信息

根据我们之前准备的数据模型,我们知道有三个关键文件需要映射:家庭信息(Household Information)、天气(Weather)和公共假日(Bank Holidays)。

informations_households.csv 文件包含有关家庭的元数据。这些是与时间无关的静态特征。为此,我们只需要基于时间序列标识符 LCLidinformations_households.csv 与紧凑型数据进行左连接。

最佳实践:

在使用 pandas 进行合并时,最常见且意想不到的结果之一是操作前后的行数不相同(即使您进行的是左连接)。这通常是因为用于合并的键中存在重复项。作为最佳实践,您可以在 pandas 合并中使用 validate 参数,该参数接受诸如 one_to_onemany_to_one 之类的输入,以便在合并时进行检查,如果假设不成立,将抛出错误。有关更多信息,请访问 pandas.merge 文档

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

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

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

现在,我们已将假日文件转换为每30分钟一个行的 DataFrame。在每一行中,我们都有一个列来指定当天是否为假日。

weather_hourly_darksky.csv 文件同样以每日频率记录。由于我们需要将其映射到半小时频率的数据,因此需要将其下采样到每30分钟一次。如果不这样做,天气信息将仅映射到每小时的时间戳,导致半小时的时间戳为空。

处理此文件的步骤与处理假日文件的方式类似:

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

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

将文件保存和加载到磁盘

完全合并后的紧凑型 DataFrame 仅占用约 10 MB。然而,保存此文件需要一些工程处理。如果我们尝试将文件保存为 CSV 格式,由于我们在 pandas 列中存储数组的方式(因为数据是紧凑型的),这将无法正常工作。我们可以将其保存为 pickle 或 parquet 格式,或者任何其他二进制文件存储形式。这取决于我们机器上可用的 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 的有用函数:

# 转换为扩展型
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

# 旋转数据以设置索引为 datetime,不同的时间序列沿列排列
plot_df = pd.pivot_table(exp_block_df, index="timestamp", columns="LCLid", values="energy_consumption")

# 生成图表。由于我们有一个 datetime 索引,可以指定频率来决定 X 轴上显示的内容
msno.matrix(plot_df, freq="M")

上述代码生成了以下输出:

image.png

仅在相关时间序列少于25个的情况下尝试使用 missingno 进行可视化。如果您的数据集中包含数千个时间序列(例如我们的完整数据集),应用这种可视化方法将导致图表难以辨认,并可能导致计算机冻结。

这种可视化在一瞥之下能告诉我们很多信息。Y轴包含我们正在绘制可视化的日期,而X轴包含列,在这种情况下是不同的家庭。我们知道所有时间序列并不完全对齐——也就是说,它们并非全部在相同的时间开始和结束。我们在许多时间序列开头看到的大白色间隙表明,这些消费者的数据收集比其他人晚开始。我们还可以看到一些时间序列比其他时间序列提前结束,这意味着它们要么停止了消费,要么测量阶段已停止。在许多时间序列中还有一些较小的白线,这是真实的缺失值。我们还可以注意到右侧有一个微型图(sparkline),它是每行缺失列数量的紧凑表示。如果没有缺失值(所有时间序列都有某些值),那么微型图将位于最右侧。最后,如果有大量缺失值,线条将位于左侧。

仅因为存在缺失值,我们不会立即填补/插补它们,因为是否插补缺失数据的决策将在工作流程的后期进行。对于某些模型,我们不需要进行插补,而对于其他模型,则需要。插补缺失数据的方法有多种,选择哪一种也是我们无法预先决定的另一个问题。

因此,现在让我们选择一个 LCLid 并深入研究。我们已经知道在2012-09-30到2012-10-31之间存在一些缺失值。让我们可视化那段时间:

# 从区块中提取单个时间序列
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")

上述代码生成了以下输出:

image.png

这里,我们可以看到缺失数据位于2012-10-18到2012-10-19之间。通常情况下,我们会直接对这一期间的缺失数据进行插补,但由于我们以学术的视角来研究这一问题,我们将采取稍微不同的方法。

让我们引入一个人工缺失数据段,观察我们将要讨论的不同技术如何对缺失数据进行插补,并计算一个指标来评估我们与真实时间序列的接近程度(我们将使用一个称为平均绝对误差(Mean Absolute Error, MAE)的指标来进行比较,它仅仅是跨时间步的绝对误差的平均值。请理解这是一个越低越好的指标,我们将在本书后面详细讨论):

# 我们将时间序列设为缺失的日期范围
window = slice("2012-10-07", "2012-10-08")

# 创建一个新列并人为制造缺失值
ts_df['energy_consumption_missing'] = ts_df.energy_consumption
ts_df.loc[window, "energy_consumption_missing"] = np.nan

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

image.png

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

我们可以使用几种技术来填补如此大的数据缺口。现在我们将介绍这些方法。

使用前一天的数据进行插补

由于这是一个半小时一次的能源消耗时间序列,因此可以推断可能存在每天重复的模式。例如,上午9:00到10:00之间的能源消耗可能较高,因为每个人都在准备上班,而在大多数房屋可能空置的白天则会下降。

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

# 将数据向前移动48步以获取前一天的数据
ts_df["prev_day"] = ts_df['energy_consumption'].shift(48)

# 使用移动后的列填补缺失值
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)
mae = mean_absolute_error(ts_df.loc[window, "prev_day_imputed"], ts_df.loc[window, "energy_consumption"])

让我们看看插补后的结果:

image.png

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

每小时平均剖面

一种更好的方法是从数据中计算每小时的剖面——每小时的平均消耗量——并使用该平均值来填补缺失的数据:

# 从 timestamp 中创建一个小时列
ts_df["hour"] = ts_df.index.hour

# 计算每小时的平均消耗量
hourly_profile = ts_df.groupby(['hour'])['energy_consumption'].mean().reset_index()
hourly_profile.rename(columns={"energy_consumption": "hourly_profile"}, inplace=True)

# 保存索引,因为在合并时会丢失
idx = ts_df.index

# 将每小时剖面数据框与时间序列数据框合并
ts_df = ts_df.merge(hourly_profile, on=['hour'], how='left', validate="many_to_one")
ts_df.index = idx

# 使用每小时剖面填补缺失值
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)
mae = mean_absolute_error(ts_df.loc[window, "hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"])

让我们看看这是否更好:

image.png

这为我们提供了一个更加通用的曲线,不再有我们在单个日子中看到的尖峰。每小时的涨跌也按预期被捕捉到了。平均绝对误差(MAE)也比之前更低。

每个工作日的每小时平均值

我们可以通过为每个工作日引入特定的剖面来进一步完善这一规则。理所当然地,工作日的使用模式不会与周末相同。因此,我们可以分别计算每个工作日的每小时平均消耗量,以便为星期一、星期二等各自创建一个剖面:

# 从 timestamp 中创建一个星期几的列
ts_df["weekday"] = ts_df.index.weekday

# 计算每个工作日每小时的平均消耗量
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)

# 保存索引,因为在合并时会丢失
idx = ts_df.index

# 将工作日每小时剖面数据框与时间序列数据框合并
ts_df = ts_df.merge(day_hourly_profile, on=['weekday', 'hour'], how='left', validate="many_to_one")
ts_df.index = idx

# 使用工作日每小时剖面填补缺失值
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)
mae = mean_absolute_error(ts_df.loc[window, "day_hourly_profile_imputed"], ts_df.loc[window, "energy_consumption"])

让我们看看这是什么效果:

image.png

这看起来与之前的方法非常相似,但这是因为我们插补的那一天是工作日,而工作日的剖面是相似的。平均绝对误差(MAE)也低于使用每日剖面的方法。周末的剖面略有不同,您可以在相关的 Jupyter 笔记本中看到这一点。

季节性插值

尽管计算季节性剖面并使用它们进行插补效果良好,但在某些情况下,特别是当时间序列中存在趋势时,这种简单的方法就显得不足。简单的季节性剖面完全无法捕捉趋势,甚至完全忽略了趋势。对于这种情况,我们可以采取以下步骤:

  1. 计算季节性剖面,类似于我们之前计算平均值的方式。
  2. 减去季节性剖面,然后应用我们之前看到的任何插值技术。
  3. 将季节性剖面返回到插补后的序列

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

from src.imputation.interpolation import SeasonalInterpolation

# 使用48*7作为季节周期进行季节性插值。
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(工作日到半小时)的季节性插值,并绘制了结果的插补效果:

image.png

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

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

总结

在本章中,在简要回顾了 pandas DataFrame,特别是关于日期时间操作和处理缺失数据的简单技术之后,我们学习了存储和处理时间序列数据的两种形式——紧凑型和扩展型。凭借这些知识,我们对原始数据集进行了处理,并建立了一个管道,将其转换为紧凑型。如果您运行了配套的笔记本,应该已经将预处理后的数据集保存到了磁盘上。我们还深入探讨了一些处理长时间缺失数据的技术。

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