Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(六)(2)https://developer.aliyun.com/article/1482441
循环神经元和层
到目前为止,我们已经专注于前馈神经网络,其中激活仅在一个方向中流动,从输入层到输出层。循环神经网络看起来非常像前馈神经网络,只是它还有指向后方的连接。
让我们看看最简单的 RNN,由一个神经元组成,接收输入,产生输出,并将该输出发送回自身,如图 15-1(左)所示。在每个时间步 t(也称为帧),这个循环神经元接收输入x[(t)]以及来自上一个时间步的自己的输出ŷ[(t–1)]。由于在第一个时间步没有先前的输出,通常将其设置为 0。我们可以沿着时间轴表示这个小网络,如图 15-1(右)所示。这被称为将网络展开到时间轴(每个时间步表示一个循环神经元)。
图 15-1. 一个循环神经元(左)在时间轴上展开(右)
您可以轻松创建一个循环神经元层。在每个时间步t,每个神经元都接收来自输入向量x[(t)]和上一个时间步的输出向量ŷ[(t–1)],如图 15-2 所示。请注意,现在输入和输出都是向量(当只有一个神经元时,输出是标量)。
图 15-2. 一个循环神经元层(左)在时间轴上展开(右)
每个递归神经元有两组权重:一组用于输入x[(t)],另一组用于上一个时间步的输出ŷ[(t–1)]。让我们称这些权重向量为w[x]和w[ŷ]。如果我们考虑整个递归层而不仅仅是一个递归神经元,我们可以将所有权重向量放入两个权重矩阵:W[x]和W[ŷ]。
整个递归层的输出向量可以按照你所期望的方式计算,如方程 15-1 所示,其中b是偏置向量,ϕ(·)是激活函数(例如,ReLU¹)。
方程 15-1. 单个实例的递归层输出
ŷ(t)=ϕWx⊺x(t)+Wŷ⊺ŷ(t-1)+b
就像前馈神经网络一样,我们可以通过将时间步t的所有输入放入输入矩阵X[(t)](参见方程 15-2)来一次性计算整个小批量的递归层输出。
方程 15-2. 一次传递中递归神经元层的所有实例的输出:[小批量
Ŷ (t) = ϕ X (t) W x + Ŷ (t-1) W ŷ + b = ϕ X (t) Ŷ (t-1) W + b with W = W x W ŷ
在这个方程中:
- Ŷ[(t)]是一个m×n[neurons]矩阵,包含小批量中每个实例在时间步t的层输出(m是小批量中实例的数量,n[neurons]是神经元的数量)。
- X[(t)]是一个m×n[inputs]矩阵,包含所有实例的输入(n[inputs]是输入特征的数量)。
- W[x]是一个n[inputs]×n[neurons]矩阵,包含当前时间步输入的连接权重。
- W[ŷ]是一个n[neurons]×n[neurons]矩阵,包含上一个时间步输出的连接权重。
- b是一个大小为n[neurons]的向量,包含每个神经元的偏置项。
- 权重矩阵W[x]和W[ŷ]通常垂直连接成一个形状为(n[inputs] + n[neurons]) × n[neurons]的单个权重矩阵W(参见方程 15-2 的第二行)。
- 符号[X[(t)] Ŷ[(t–1)]]表示矩阵X[(t)]和Ŷ[(t–1)]的水平连接。
注意,Ŷ[(t)]是X[(t)]和Ŷ[(t–1)]的函数,X[(t–1)]和Ŷ[(t–2)]的函数,X[(t–2)]和Ŷ[(t–3)]的函数,依此类推。这使得Ŷ[(t)]是自时间t=0(即X[(0)], X[(1)], …, X[(t)])以来所有输入的函数。在第一个时间步骤,t=0 时,没有先前的输出,因此通常假定它们都是零。
记忆单元
由于递归神经元在时间步骤t的输出是前几个时间步骤的所有输入的函数,因此可以说它具有一种记忆形式。在时间步骤之间保留一些状态的神经网络的一部分称为记忆单元(或简称单元)。单个递归神经元或一层递归神经元是一个非常基本的单元,只能学习短模式(通常约为 10 个步骤长,但这取决于任务)。在本章后面,我们将看一些更复杂和强大的单元类型,能够学习更长的模式(大约长 10 倍,但这也取决于任务)。
时间步骤t时的单元状态,表示为h[(t)](“h”代表“隐藏”),是该时间步骤的一些输入和上一个时间步骤的状态的函数:h[(t)] = f(x[(t)], h[(t–1)])。在时间步骤t的输出,表示为ŷ[(t)],也是前一个状态和当前输入的函数。在我们迄今讨论的基本单元的情况下,输出只等于状态,但在更复杂的单元中,情况并非总是如此,如图 15-3 所示。
图 15-3。单元的隐藏状态和输出可能不同
输入和输出序列
RNN 可以同时接受一系列输入并产生一系列输出(参见图 15-4 左上方的网络)。这种序列到序列网络对于预测时间序列非常有用,例如您家每天的用电量:您向其提供过去N天的数据,并训练它输出将来一天的用电量(即从N – 1 天前到明天)。
或者,您可以向网络提供一系列输入并忽略除最后一个之外的所有输出(参见图 15-4 右上方的网络)。这是一个序列到向量网络。例如,您可以向网络提供与电影评论相对应的一系列单词,网络将输出情感分数(例如,从 0 [讨厌]到 1 [喜爱])。
相反,您可以在每个时间步骤反复向网络提供相同的输入向量,并让它输出一个序列(参见图 15-4 左下方的网络)。这是一个向量到序列网络。例如,输入可以是一幅图像(或 CNN 的输出),输出可以是该图像的标题。
最后,您可以有一个序列到向量网络,称为编码器,后面是一个向量到序列网络,称为解码器(参见图 15-4 的右下方网络)。例如,这可以用于将一种语言的句子翻译成另一种语言。您将向网络提供一种语言的句子,编码器将把这个句子转换成一个单一的向量表示,然后解码器将把这个向量解码成另一种语言的句子。这种两步模型,称为编码器-解码器²比尝试使用单个序列到序列的 RNN 实时翻译要好得多(就像左上角表示的那种):一个句子的最后几个词可能会影响翻译的前几个词,因此您需要等到看完整个句子后再进行翻译。我们将在第十六章中介绍编码器-解码器的实现(正如您将看到的,它比图 15-4 所暗示的要复杂一些)。
图 15-4. 序列到序列(左上)、序列到向量(右上)、向量到序列(左下)和编码器-解码器(右下)网络
这种多功能性听起来很有前途,但如何训练循环神经网络呢?
训练 RNNs
要训练 RNN,关键是将其通过时间展开(就像我们刚刚做的那样),然后使用常规的反向传播(参见图 15-5)。这种策略称为通过时间的反向传播(BPTT)。
就像常规反向传播一样,首先通过展开的网络进行第一次前向传递(由虚线箭头表示)。然后使用损失函数ℒ(Y[(0)], Y[(1)], …, Y[(T)]; Ŷ[(0)], Ŷ[(1)], …, Ŷ[(T)])评估输出序列(其中Y[(i)]是第i个目标,Ŷ[(i)]是第i个预测,T是最大时间步长)。请注意,此损失函数可能会忽略一些输出。例如,在序列到向量的 RNN 中,除了最后一个输出之外,所有输出都会被忽略。在图 15-5 中,损失函数仅基于最后三个输出计算。然后,该损失函数的梯度通过展开的网络向后传播(由实线箭头表示)。在这个例子中,由于输出Ŷ[(0)]和Ŷ[(1)]没有用于计算损失,梯度不会通过它们向后传播;它们只会通过Ŷ[(2)]、Ŷ[(3)]和Ŷ[(4)]向后传播。此外,由于在每个时间步骤中使用相同的参数W和b,它们的梯度将在反向传播过程中被多次调整。一旦反向阶段完成并计算出所有梯度,BPTT 可以执行梯度下降步骤来更新参数(这与常规反向传播没有区别)。
图 15-5. 通过时间反向传播
幸运的是,Keras 会为您处理所有这些复杂性,您将看到。但在我们到达那里之前,让我们加载一个时间序列,并开始使用传统工具进行分析,以更好地了解我们正在处理的内容,并获得一些基准指标。
预测时间序列
好了!假设您刚被芝加哥交通管理局聘为数据科学家。您的第一个任务是构建一个能够预测明天公交和轨道乘客数量的模型。您可以访问自 2001 年以来的日常乘客数据。让我们一起看看您将如何处理这个问题。我们将从加载和清理数据开始:
import pandas as pd from pathlib import Path path = Path("datasets/ridership/CTA_-_Ridership_-_Daily_Boarding_Totals.csv") df = pd.read_csv(path, parse_dates=["service_date"]) df.columns = ["date", "day_type", "bus", "rail", "total"] # shorter names df = df.sort_values("date").set_index("date") df = df.drop("total", axis=1) # no need for total, it's just bus + rail df = df.drop_duplicates() # remove duplicated months (2011-10 and 2014-07)
我们加载 CSV 文件,设置短列名,按日期对行进行排序,删除多余的total
列,并删除重复行。现在让我们看看前几行是什么样子的:
>>> df.head() day_type bus rail date 2001-01-01 U 297192 126455 2001-01-02 W 780827 501952 2001-01-03 W 824923 536432 2001-01-04 W 870021 550011 2001-01-05 W 890426 557917
在 2001 年 1 月 1 日,芝加哥有 297,192 人乘坐公交车,126,455 人乘坐火车。day_type
列包含W
表示工作日,A
表示周六,U
表示周日或假期。
现在让我们绘制 2019 年几个月的公交和火车乘客量数据,看看它是什么样子的(参见图 15-6):
import matplotlib.pyplot as plt df["2019-03":"2019-05"].plot(grid=True, marker=".", figsize=(8, 3.5)) plt.show()
图 15-6。芝加哥的日常乘客量
请注意,Pandas 在范围中包括起始月份和结束月份,因此这将绘制从 3 月 1 日到 5 月 31 日的数据。这是一个时间序列:在不同时间步长上具有值的数据,通常在规则间隔上。更具体地说,由于每个时间步长有多个值,因此称为多变量时间序列。如果我们只看bus
列,那将是一个单变量时间序列,每个时间步长有一个值。在处理时间序列时,预测未来值(即预测)是最典型的任务,这也是我们将在本章中重点关注的内容。其他任务包括插补(填补缺失的过去值)、分类、异常检测等。
查看图 15-6,我们可以看到每周明显重复的类似模式。这被称为每周季节性。实际上,在这种情况下,季节性非常强,通过简单地复制一周前的值来预测明天的乘客量将产生相当不错的结果。这被称为天真预测:简单地复制过去的值来进行预测。天真预测通常是一个很好的基准,有时甚至在某些情况下很难超越。
注意
一般来说,天真预测意味着复制最新已知值(例如,预测明天与今天相同)。然而,在我们的情况下,复制上周的值效果更好,因为存在强烈的每周季节性。
为了可视化这些天真预测,让我们将两个时间序列(公交和火车)以及相同时间序列向右移动一周(即向右移动)的时间序列叠加使用虚线。我们还将绘制两者之间的差异(即时间t处的值减去时间t - 7 处的值);这称为差分(参见图 15-7):
diff_7 = df[["bus", "rail"]].diff(7)["2019-03":"2019-05"] fig, axs = plt.subplots(2, 1, sharex=True, figsize=(8, 5)) df.plot(ax=axs[0], legend=False, marker=".") # original time series df.shift(7).plot(ax=axs[0], grid=True, legend=False, linestyle=":") # lagged diff_7.plot(ax=axs[1], grid=True, marker=".") # 7-day difference time series plt.show()
不错!注意滞后时间序列如何紧密跟踪实际时间序列。当一个时间序列与其滞后版本相关联时,我们说该时间序列是自相关的。正如您所看到的,大多数差异都相当小,除了五月底。也许那时有一个假期?让我们检查day_type
列:
>>> list(df.loc["2019-05-25":"2019-05-27"]["day_type"]) ['A', 'U', 'U']
图 15-7。与 7 天滞后时间序列叠加的时间序列(顶部),以及t和t - 7 之间的差异(底部)
事实上,那时有一个长周末:周一是阵亡将士纪念日假期。我们可以使用这一列来改进我们的预测,但现在让我们只测量我们任意关注的三个月期间(2019 年 3 月、4 月和 5 月)的平均绝对误差,以获得一个大致的概念:
>>> diff_7.abs().mean() bus 43915.608696 rail 42143.271739 dtype: float64
我们的天真预测得到大约 43,916 名公交乘客和约 42,143 名火车乘客的 MAE。一眼看去很难判断这是好是坏,所以让我们将预测误差放入透视中,通过将它们除以目标值来进行评估:
>>> targets = df[["bus", "rail"]]["2019-03":"2019-05"] >>> (diff_7 / targets).abs().mean() bus 0.082938 rail 0.089948 dtype: float64
我们刚刚计算的是平均绝对百分比误差(MAPE):看起来我们的天真预测为公交大约为 8.3%,火车为 9.0%。有趣的是,火车预测的 MAE 看起来比公交预测的稍好一些,而 MAPE 则相反。这是因为公交乘客量比火车乘客量大,因此自然预测误差也更大,但当我们将误差放入透视时,结果表明公交预测实际上略优于火车预测。
提示
MAE、MAPE 和 MSE 是评估预测的最常见指标之一。与往常一样,选择正确的指标取决于任务。例如,如果您的项目对大误差的影响是小误差的平方倍,那么 MSE 可能更可取,因为它会严厉惩罚大误差。
观察时间序列,似乎没有明显的月度季节性,但让我们检查一下是否存在年度季节性。我们将查看 2001 年至 2019 年的数据。为了减少数据窥探的风险,我们暂时忽略更近期的数据。让我们为每个系列绘制一个 12 个月的滚动平均线,以可视化长期趋势(参见图 15-8):
period = slice("2001", "2019") df_monthly = df.resample('M').mean() # compute the mean for each month rolling_average_12_months = df_monthly[period].rolling(window=12).mean() fig, ax = plt.subplots(figsize=(8, 4)) df_monthly[period].plot(ax=ax, marker=".") rolling_average_12_months.plot(ax=ax, grid=True, legend=False) plt.show()
图 15-8。年度季节性和长期趋势
是的!确实存在一些年度季节性,尽管比每周季节性更嘈杂,对于铁路系列而言更为明显,而不是公交系列:我们看到每年大致相同日期出现高峰和低谷。让我们看看如果绘制 12 个月的差分会得到什么(参见图 15-9):
df_monthly.diff(12)[period].plot(grid=True, marker=".", figsize=(8, 3)) plt.show()
图 15-9。12 个月的差分
注意,差分不仅消除了年度季节性,还消除了长期趋势。例如,2016 年至 2019 年时间序列中存在的线性下降趋势在差分时间序列中变为大致恒定的负值。事实上,差分是一种常用的技术,用于消除时间序列中的趋势和季节性:研究平稳时间序列更容易,这意味着其统计特性随时间保持不变,没有任何季节性或趋势。一旦您能够对差分时间序列进行准确的预测,只需将先前减去的过去值添加回来,就可以将其转换为实际时间序列的预测。
您可能会认为我们只是试图预测明天的乘客量,因此长期模式比短期模式更不重要。您是对的,但是,通过考虑长期模式,我们可能能够稍微提高性能。例如,2017 年 10 月,每日公交乘客量减少了约 2500 人,这代表每周减少约 570 名乘客,因此如果我们处于 2017 年 10 月底,通过从上周复制数值,减去 570,来预测明天的乘客量是有道理的。考虑趋势将使您的平均预测略微更准确。
现在您熟悉了乘客量时间序列,以及时间序列分析中一些最重要的概念,包括季节性、趋势、差分和移动平均,让我们快速看一下一个非常流行的统计模型家族,通常用于分析时间序列。
ARMA 模型家族
我们将从上世纪 30 年代由赫尔曼·沃尔德(Herman Wold)开发的自回归移动平均(ARMA)模型开始:它通过对滞后值的简单加权和添加移动平均来计算其预测,非常类似我们刚刚讨论的。具体来说,移动平均分量是通过最近几个预测误差的加权和来计算的。方程 15-3 展示了该模型如何进行预测。
第 15-3 方程。使用 ARMA 模型进行预测
y^(t)=∑i=1pαiy(t-i)+∑i=1qθiϵ(t-i)with ϵ(t)=y(t)-y^(t)
在这个方程中:
- ŷ[(t)]是模型对时间步t的预测。
- y[(t)]是时间步t的时间序列值。
- 第一个总和是时间序列过去p个值的加权和,使用学习到的权重α[i]。数字p是一个超参数,它决定模型应该查看过去多远。这个总和是模型的自回归组件:它基于过去的值执行回归。
- 第二个总和是过去q个预测误差ε[(t)]的加权和,使用学习到的权重θ[i]。数字q是一个超参数。这个总和是模型的移动平均组件。
重要的是,这个模型假设时间序列是平稳的。如果不是,那么差分可能有所帮助。在一个时间步上使用差分将产生时间序列的导数的近似值:实际上,它将给出每个时间步的系列斜率。这意味着它将消除任何线性趋势,将其转换为一个常数值。例如,如果你对系列[3, 5, 7, 9, 11]应用一步差分,你会得到差分系列[2, 2, 2, 2]。
如果原始时间序列具有二次趋势而不是线性趋势,那么一轮差分将不足够。例如,系列[1, 4, 9, 16, 25, 36]经过一轮差分后变为[3, 5, 7, 9, 11],但如果你再进行第二轮差分,你会得到[2, 2, 2, 2]。因此,进行两轮差分将消除二次趋势。更一般地,连续运行d轮差分计算时间序列的d阶导数的近似值,因此它将消除多项式趋势直到d阶。这个超参数d被称为积分阶数。
差分是 1970 年由乔治·博克斯和格威林·詹金斯在他们的书《时间序列分析》(Wiley)中介绍的自回归积分移动平均(ARIMA)模型的核心贡献:这个模型运行d轮差分使时间序列更平稳,然后应用常规 ARMA 模型。在进行预测时,它使用这个 ARMA 模型,然后将差分减去的项加回来。
ARMA 家族的最后一个成员是季节性 ARIMA(SARIMA)模型:它以与 ARIMA 相同的方式对时间序列建模,但另外还为给定频率(例如每周)建模一个季节性组件,使用完全相同的 ARIMA 方法。它总共有七个超参数:与 ARIMA 相同的p、d和q超参数,再加上额外的P、D和Q超参数来建模季节性模式,最后是季节性模式的周期,标记为s。超参数P、D和Q就像p、d和q一样,但它们用于模拟时间序列在t – s、t – 2s、t – 3s等时刻。
让我们看看如何将 SARIMA 模型拟合到铁路时间序列,并用它来预测明天的乘客量。我们假设今天是 2019 年 5 月的最后一天,我们想要预测“明天”,也就是 2019 年 6 月 1 日的铁路乘客量。为此,我们可以使用statsmodels
库,其中包含许多不同的统计模型,包括由ARIMA
类实现的 ARMA 模型及其变体:
from statsmodels.tsa.arima.model import ARIMA origin, today = "2019-01-01", "2019-05-31" rail_series = df.loc[origin:today]["rail"].asfreq("D") model = ARIMA(rail_series, order=(1, 0, 0), seasonal_order=(0, 1, 1, 7)) model = model.fit() y_pred = model.forecast() # returns 427,758.6
在这个代码示例中:
- 我们首先导入
ARIMA
类,然后我们从 2019 年初开始到“今天”获取铁路乘客数据,并使用asfreq("D")
将时间序列的频率设置为每天:在这种情况下,这不会改变数据,因为它已经是每天的,但如果没有这个,ARIMA
类将不得不猜测频率,并显示警告。 - 接下来,我们创建一个
ARIMA
实例,将所有数据传递到“今天”,并设置模型超参数:order=(1, 0, 0)
表示p=1,d=0,q=0,seasonal_order=(0, 1, 1, 7)
表示P=0,D=1,Q=1,s=7。请注意,statsmodels
API 与 Scikit-Learn 的 API 有些不同,因为我们在构建时将数据传递给模型,而不是将数据传递给fit()
方法。 - 接下来,我们拟合模型,并用它为“明天”,也就是 2019 年 6 月 1 日,做出预测。
预测为 427,759 名乘客,而实际上有 379,044 名。哎呀,我们偏差 12.9%——这相当糟糕。实际上,这比天真预测稍微糟糕,天真预测为 426,932,偏差为 12.6%。但也许那天我们只是运气不好?为了检查这一点,我们可以在循环中运行相同的代码,为三月、四月和五月的每一天进行预测,并计算该期间的平均绝对误差:
origin, start_date, end_date = "2019-01-01", "2019-03-01", "2019-05-31" time_period = pd.date_range(start_date, end_date) rail_series = df.loc[origin:end_date]["rail"].asfreq("D") y_preds = [] for today in time_period.shift(-1): model = ARIMA(rail_series[origin:today], # train on data up to "today" order=(1, 0, 0), seasonal_order=(0, 1, 1, 7)) model = model.fit() # note that we retrain the model every day! y_pred = model.forecast()[0] y_preds.append(y_pred) y_preds = pd.Series(y_preds, index=time_period) mae = (y_preds - rail_series[time_period]).abs().mean() # returns 32,040.7
啊,好多了!平均绝对误差约为 32,041,比我们用天真预测得到的平均绝对误差(42,143)显著低。因此,虽然模型并不完美,但平均而言仍然远远超过天真预测。
此时,您可能想知道如何为 SARIMA 模型选择良好的超参数。有几种方法,但最简单的方法是粗暴的方法:进行网格搜索。对于要评估的每个模型(即每个超参数组合),您可以运行前面的代码示例,仅更改超参数值。通常p、q、P和Q值较小(通常为 0 到 2,有时可达 5 或 6),d和D通常为 0 或 1,有时为 2。至于s,它只是主要季节模式的周期:在我们的情况下是 7,因为有强烈的每周季节性。具有最低平均绝对误差的模型获胜。当然,如果它更符合您的业务目标,您可以用另一个指标替换平均绝对误差。就是这样!
为机器学习模型准备数据
现在我们有了两个基线,天真预测和 SARIMA,让我们尝试使用迄今为止涵盖的机器学习模型来预测这个时间序列,首先从基本的线性模型开始。我们的目标是根据过去 8 周(56 天)的数据来预测明天的乘客量。因此,我们模型的输入将是序列(通常是生产中的每天一个序列),每个序列包含从时间步t - 55 到t的 56 个值。对于每个输入序列,模型将输出一个值:时间步t + 1 的预测。
但我们将使用什么作为训练数据呢?嗯,这就是诀窍:我们将使用过去的每个 56 天窗口作为训练数据,每个窗口的目标将是紧随其后的值。
Keras 实际上有一个很好的实用函数称为tf.keras.utils.timeseries_dataset_from_array()
,帮助我们准备训练集。它以时间序列作为输入,并构建一个 tf.data.Dataset(在第十三章中介绍)包含所需长度的所有窗口,以及它们对应的目标。以下是一个示例,它以包含数字 0 到 5 的时间序列为输入,并创建一个包含所有长度为 3 的窗口及其对应目标的数据集,分组成大小为 2 的批次:
import tensorflow as tf my_series = [0, 1, 2, 3, 4, 5] my_dataset = tf.keras.utils.timeseries_dataset_from_array( my_series, targets=my_series[3:], # the targets are 3 steps into the future sequence_length=3, batch_size=2 )
让我们检查一下这个数据集的内容:
>>> list(my_dataset) [(<tf.Tensor: shape=(2, 3), dtype=int32, numpy= array([[0, 1, 2], [1, 2, 3]], dtype=int32)>, <tf.Tensor: shape=(2,), dtype=int32, numpy=array([3, 4], dtype=int32)>), (<tf.Tensor: shape=(1, 3), dtype=int32, numpy=array([[2, 3, 4]], dtype=int32)>, <tf.Tensor: shape=(1,), dtype=int32, numpy=array([5], dtype=int32)>)]
数据集中的每个样本是长度为 3 的窗口,以及其对应的目标(即窗口后面的值)。窗口是[0, 1, 2],[1, 2, 3]和[2, 3, 4],它们各自的目标是 3,4 和 5。由于总共有三个窗口,不是批次大小的倍数,最后一个批次只包含一个窗口而不是两个。
另一种获得相同结果的方法是使用 tf.data 的Dataset
类的window()
方法。这更复杂,但它给了您完全的控制,这将在本章后面派上用场,让我们看看它是如何工作的。window()
方法返回一个窗口数据集的数据集:
>>> for window_dataset in tf.data.Dataset.range(6).window(4, shift=1): ... for element in window_dataset: ... print(f"{element}", end=" ") ... print() ... 0 1 2 3 1 2 3 4 2 3 4 5 3 4 5 4 5 5
在这个例子中,数据集包含六个窗口,每个窗口相对于前一个窗口向前移动一个步骤,最后三个窗口较小,因为它们已经到达系列的末尾。通常情况下,您会希望通过向window()
方法传递drop_remainder=True
来摆脱这些较小的窗口。
window()
方法返回一个嵌套数据集,类似于一个列表的列表。当您想要通过调用其数据集方法(例如,对它们进行洗牌或分批处理)来转换每个窗口时,这将非常有用。然而,我们不能直接使用嵌套数据集进行训练,因为我们的模型将期望张量作为输入,而不是数据集。
因此,我们必须调用flat_map()
方法:它将嵌套数据集转换为平坦数据集(包含张量而不是数据集)。例如,假设{1, 2, 3}表示包含张量 1、2 和 3 序列的数据集。如果展平嵌套数据集{{1, 2}, {3, 4, 5, 6}},您将得到平坦数据集{1, 2, 3, 4, 5, 6}。
此外,flat_map()
方法接受一个函数作为参数,允许您在展平之前转换嵌套数据集中的每个数据集。例如,如果您将函数lambda ds: ds.batch(2)
传递给flat_map()
,那么它将把嵌套数据集{{1, 2}, {3, 4, 5, 6}}转换为平坦数据集{[1, 2], [3, 4], [5, 6]}:这是一个包含 3 个大小为 2 的张量的数据集。
考虑到这一点,我们准备对数据集进行展平处理:
>>> dataset = tf.data.Dataset.range(6).window(4, shift=1, drop_remainder=True) >>> dataset = dataset.flat_map(lambda window_dataset: window_dataset.batch(4)) >>> for window_tensor in dataset: ... print(f"{window_tensor}") ... [0 1 2 3] [1 2 3 4] [2 3 4 5]
由于每个窗口数据集恰好包含四个项目,对窗口调用batch(4)
会产生一个大小为 4 的单个张量。太棒了!现在我们有一个包含连续窗口的数据集,表示为张量。让我们创建一个小助手函数,以便更容易地从数据集中提取窗口:
def to_windows(dataset, length): dataset = dataset.window(length, shift=1, drop_remainder=True) return dataset.flat_map(lambda window_ds: window_ds.batch(length))
最后一步是使用map()
方法将每个窗口拆分为输入和目标。我们还可以将生成的窗口分组成大小为 2 的批次:
>>> dataset = to_windows(tf.data.Dataset.range(6), 4) # 3 inputs + 1 target = 4 >>> dataset = dataset.map(lambda window: (window[:-1], window[-1])) >>> list(dataset.batch(2)) [(<tf.Tensor: shape=(2, 3), dtype=int64, numpy= array([[0, 1, 2], [1, 2, 3]])>, <tf.Tensor: shape=(2,), dtype=int64, numpy=array([3, 4])>), (<tf.Tensor: shape=(1, 3), dtype=int64, numpy=array([[2, 3, 4]])>, <tf.Tensor: shape=(1,), dtype=int64, numpy=array([5])>)]
正如您所看到的,我们现在得到了与之前使用timeseries_dataset_from_array()
函数相同的输出(稍微费劲一些,但很快就会值得)。
现在,在开始训练之前,我们需要将数据分为训练期、验证期和测试期。我们现在将专注于铁路乘客量。我们还将通过一百万分之一的比例缩小它,以确保值接近 0-1 范围;这与默认的权重初始化和学习率很好地配合:
rail_train = df["rail"]["2016-01":"2018-12"] / 1e6 rail_valid = df["rail"]["2019-01":"2019-05"] / 1e6 rail_test = df["rail"]["2019-06":] / 1e6
注意
处理时间序列时,通常希望按时间划分。但在某些情况下,您可能能够沿其他维度划分,这将使您有更长的时间段进行训练。例如,如果您有关于 2001 年至 2019 年间 10,000 家公司财务状况的数据,您可能能够将这些数据分割到不同的公司。然而,很可能这些公司中的许多将强相关(例如,整个经济部门可能一起上涨或下跌),如果在训练集和测试集中有相关的公司,那么您的测试集将不会那么有用,因为其泛化误差的度量将是乐观偏倚的。
接下来,让我们使用timeseries_dataset_from_array()
为训练和验证创建数据集。由于梯度下降期望训练集中的实例是独立同分布的(IID),正如我们在第四章中看到的那样,我们必须设置参数shuffle=True
来对训练窗口进行洗牌(但不洗牌其中的内容):
seq_length = 56 train_ds = tf.keras.utils.timeseries_dataset_from_array( rail_train.to_numpy(), targets=rail_train[seq_length:], sequence_length=seq_length, batch_size=32, shuffle=True, seed=42 ) valid_ds = tf.keras.utils.timeseries_dataset_from_array( rail_valid.to_numpy(), targets=rail_valid[seq_length:], sequence_length=seq_length, batch_size=32 )
现在我们已经准备好构建和训练任何回归模型了!
使用线性模型进行预测
让我们首先尝试一个基本的线性模型。我们将使用 Huber 损失,通常比直接最小化 MAE 效果更好,如第十章中讨论的那样。我们还将使用提前停止:
tf.random.set_seed(42) model = tf.keras.Sequential([ tf.keras.layers.Dense(1, input_shape=[seq_length]) ]) early_stopping_cb = tf.keras.callbacks.EarlyStopping( monitor="val_mae", patience=50, restore_best_weights=True) opt = tf.keras.optimizers.SGD(learning_rate=0.02, momentum=0.9) model.compile(loss=tf.keras.losses.Huber(), optimizer=opt, metrics=["mae"]) history = model.fit(train_ds, validation_data=valid_ds, epochs=500, callbacks=[early_stopping_cb])
该模型达到了约 37,866 的验证 MAE(结果可能有所不同)。这比天真的预测要好,但比 SARIMA 模型要差。⁵
我们能用 RNN 做得更好吗?让我们看看!
使用简单 RNN 进行预测
让我们尝试最基本的 RNN,其中包含一个具有一个循环神经元的单个循环层,就像我们在图 15-1 中看到的那样:
model = tf.keras.Sequential([ tf.keras.layers.SimpleRNN(1, input_shape=[None, 1]) ])
Keras 中的所有循环层都期望形状为[批量大小,时间步长,维度]的 3D 输入,其中维度对于单变量时间序列为 1,对于多变量时间序列为更多。请记住,input_shape
参数忽略第一个维度(即批量大小),由于循环层可以接受任意长度的输入序列,因此我们可以将第二个维度设置为None
,表示“任意大小”。最后,由于我们处理的是单变量时间序列,我们需要最后一个维度的大小为 1。这就是为什么我们指定输入形状为[None, 1]
:它表示“任意长度的单变量序列”。请注意,数据集实际上包含形状为[批量大小,时间步长]的输入,因此我们缺少最后一个维度,大小为 1,但在这种情况下,Keras 很友好地为我们添加了它。
这个模型的工作方式与我们之前看到的完全相同:初始状态h[(init)]设置为 0,并传递给一个单个的循环神经元,以及第一个时间步的值x[(0)]。神经元计算这些值加上偏置项的加权和,并使用默认的双曲正切函数对结果应用激活函数。结果是第一个输出y[0]。在简单 RNN 中,这个输出也是新状态h[0]。这个新状态传递给相同的循环神经元,以及下一个输入值x[(1)],并且这个过程重复直到最后一个时间步。最后,该层只输出最后一个值:在我们的情况下,序列长度为 56 步,因此最后一个值是y[55]。所有这些都同时为批次中的每个序列执行,本例中有 32 个序列。
注意
默认情况下,Keras 中的循环层只返回最终输出。要使它们返回每个时间步的一个输出,您必须设置return_sequences=True
,如您将看到的。
这就是我们的第一个循环模型!这是一个序列到向量的模型。由于只有一个输出神经元,输出向量的大小为 1。
现在,如果您编译、训练和评估这个模型,就像之前的模型一样,您会发现它一点用也没有:其验证 MAE 大于 100,000!哎呀。这是可以预料到的,有两个原因:
- 该模型只有一个循环神经元,因此在每个时间步进行预测时,它只能使用当前时间步的输入值和上一个时间步的输出值。这不足以进行预测!换句话说,RNN 的记忆极为有限:只是一个数字,它的先前输出。让我们来数一下这个模型有多少参数:由于只有一个循环神经元,只有两个输入值,整个模型只有三个参数(两个权重加上一个偏置项)。这对于这个时间序列来说远远不够。相比之下,我们之前的模型可以一次查看所有 56 个先前的值,并且总共有 57 个参数。
- 时间序列包含的值从 0 到约 1.4,但由于默认激活函数是 tanh,循环层只能输出-1 到+1 之间的值。它无法预测 1.0 到 1.4 之间的值。
让我们解决这两个问题:我们将创建一个具有更大的循环层的模型,其中包含 32 个循环神经元,并在其顶部添加一个密集的输出层,其中只有一个输出神经元,没有激活函数。循环层将能够在一个时间步到下一个时间步传递更多信息,而密集输出层将把最终输出从 32 维投影到 1 维,没有任何值范围约束:
univar_model = tf.keras.Sequential([ tf.keras.layers.SimpleRNN(32, input_shape=[None, 1]), tf.keras.layers.Dense(1) # no activation function by default ])
现在,如果您像之前那样编译、拟合和评估这个模型,您会发现其验证 MAE 达到了 27,703。这是迄今为止我们训练过的最佳模型,甚至击败了 SARIMA 模型:我们做得相当不错!
提示
我们只对时间序列进行了归一化,没有去除趋势和季节性,但模型仍然表现良好。这很方便,因为这样可以快速搜索有前途的模型,而不用太担心预处理。然而,为了获得最佳性能,您可能希望尝试使时间序列更加平稳;例如,使用差分。
使用深度 RNN 进行预测
通常会堆叠多层单元,如图 15-10 所示。这给你一个深度 RNN。
图 15-10. 深度 RNN(左)在时间轴上展开(右)
使用 Keras 实现深度 RNN 很简单:只需堆叠循环层。在下面的示例中,我们使用三个SimpleRNN
层(但我们也可以使用任何其他类型的循环层,如LSTM
层或GRU
层,我们将很快讨论)。前两个是序列到序列层,最后一个是序列到向量层。最后,Dense
层生成模型的预测(您可以将其视为向量到向量层)。因此,这个模型就像图 15-10 中表示的模型一样,只是忽略了Ŷ[(0)]到Ŷ[(t–1_)]的输出,并且在Ŷ[(t)]之上有一个密集层,输出实际预测:
deep_model = tf.keras.Sequential([ tf.keras.layers.SimpleRNN(32, return_sequences=True, input_shape=[None, 1]), tf.keras.layers.SimpleRNN(32, return_sequences=True), tf.keras.layers.SimpleRNN(32), tf.keras.layers.Dense(1) ])
警告
确保对所有循环层设置return_sequences=True
(除非您只关心最后的输出,最后一个循环层除外)。如果您忘记为一个循环层设置此参数,它将输出一个 2D 数组,其中仅包含最后一个时间步的输出,而不是包含所有时间步输出的 3D 数组。下一个循环层将抱怨您没有以预期的 3D 格式提供序列。
如果您训练和评估这个模型,您会发现它的 MAE 约为 31,211。这比两个基线都要好,但它并没有击败我们的“更浅”的 RNN。看起来这个 RNN 对我们的任务来说有点太大了。
Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(六)(4)https://developer.aliyun.com/article/1482445