机器学习算法交易教程第二版-八-

262 阅读1小时+

机器学习算法交易教程第二版(八)

原文:zh.annas-archive.org/md5/a3861c820dde5bc35d4f0200e43cd519

译者:飞龙

协议:CC BY-NC-SA 4.0

第二十章:自编码器用于条件风险因子和资产定价

本章展示了无监督学习如何利用深度学习进行交易。更具体地说,我们将讨论数十年来存在但最近引起新关注的自编码器

无监督学习解决了实际机器学习挑战,比如有标签数据的有限可用性和维度灾难,后者需要指数级更多的样本才能成功地从具有许多特征的复杂现实数据中学习。在概念层面上,无监督学习更类似于人类学习和常识的发展,而不是监督学习和强化学习,我们将在下一章中介绍。它也被称为预测学习,因为它旨在从数据中发现结构和规律,以便可以预测缺失的输入,也就是从观察到的部分填补空白。

自编码器是一个神经网络NN)训练成为复制输入同时学习数据的新表示的神经网络,由隐藏层的参数编码。自编码器长期以来一直用于非线性降维和流形学习(见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)。各种设计利用了我们在最后三章中介绍的前馈、卷积和递归网络架构。我们将看到自编码器如何支撑一个交易策略:我们将构建一个深度神经网络,该网络使用自编码器来提取风险因子并预测股票回报,条件是一系列股票属性(Gu、Kelly 和 Xiu 2020)。

更具体地说,在本章中,您将学习以下内容:

  • 哪些类型的自编码器具有实际用途以及它们的工作原理

  • 使用 Python 构建和训练自编码器

  • 使用自编码器提取考虑资产特征以预测回报的数据驱动风险因子

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和其他资源的链接。笔记本包括图像的彩色版本。

用于非线性特征提取的自编码器

第十七章交易的深度学习 中,我们看到神经网络如何通过提取对给定任务有用的分层特征表示而成功进行监督学习。例如,卷积神经网络CNNs)从类似网格的数据中学习和合成越来越复杂的模式,例如,在图像中识别或检测对象,或者对时间序列进行分类。

与之相反,自编码器是专门设计用来学习一个新表示的神经网络,该表示以一种有助于解决另一个任务的方式对输入进行编码。为此,训练强制网络重现输入。由于自编码器通常使用相同的数据作为输入和输出,它们也被视为自监督学习的一种实例。在这个过程中,隐藏层的参数h成为表示输入的编码,类似于第十六章*《用于收益电话和 SEC 文件的词嵌入》*中介绍的 word2vec 模型。

更具体地说,网络可以被视为由一个编码器函数h=f(x)和一个解码器函数g组成,编码器函数从输入x中学习隐藏层的参数,解码器函数学习从编码h中重构输入。而不是学习身份函数:

自编码器不是简单地复制输入,而是使用约束来强制隐藏层优先编码数据的哪些方面。目标是获得实用价值的表示。

自编码器也可以被视为前馈神经网络的特例(见第十七章,用于交易的深度学习),并且可以使用相同的技术进行训练。与其他模型一样,过量的容量会导致过拟合,阻止自编码器产生超出训练样本的通用编码。有关更多背景信息,请参阅 Goodfellow、Bengio 和 Courville (2016)的第 1415章。

泛化线性降维

传统用例包括降维,通过限制隐藏层的大小从而创建一个“瓶颈”,使其执行有损压缩。这样的自编码器被称为欠完备,其目的是通过最小化形式为L的损失函数来学习数据的最显著属性:

我们将在下一节中探讨的一个示例损失函数仅仅是在输入图像的像素值及其重构上计算的均方误差。当我们构建用于交易的条件自编码器时,我们还将使用这个损失函数来从金融特征的时间序列中提取风险因子。

与主成分分析(PCA;见第十三章,用于无监督学习的数据驱动风险因子和资产配置)等线性降维方法不同,欠完备自编码器使用非线性激活函数;否则,它们学习与 PCA 相同的子空间。因此,它们可以被视为 PCA 的非线性推广,能够学习更广泛的编码。

图 20.1说明了具有三个隐藏层的欠完备前馈自编码器的编码器-解码器逻辑:编码器和解码器各有一个隐藏层,再加上包含编码的共享编码器输出/解码器输入层。这三个隐藏层使用非线性激活函数,如修正线性单元ReLU)、sigmoidtanh(参见第十七章交易的深度学习),并且比网络要重建的输入单元更少。

图 20.1:欠完备编码器-解码器架构

根据任务的不同,一个具有单个编码器和解码器层的简单自编码器可能是足够的。然而,具有额外层的更深的自编码器可以有几个优点,就像对其他神经网络一样。这些优点包括学习更复杂的编码、实现更好的压缩,并且在更少的计算和更少的训练样本的情况下完成,但会受到过拟合的固有风险的影响。

用于图像压缩的卷积自编码器

第十八章所讨论的,用于金融时间序列和卫星图像的 CNN,全连接前馈架构不适合捕获具有网格结构的数据的局部相关性。相反,自编码器也可以使用卷积层来学习分层特征表示。卷积自编码器利用卷积和参数共享来学习层次化模式和特征,而不受其位置、平移或大小变化的影响。

我们将在下面为图像数据演示卷积自编码器的不同实现。或者,卷积自编码器也可以应用于网格形式排列的多变量时间序列数据,如第十八章所示,用于金融时间序列和卫星图像的 CNN

通过正则化自编码器管理过拟合

神经网络表示复杂函数的强大能力要求对编码器和解码器的容量进行严格控制,以提取信号而不是噪声,从而使编码更适用于下游任务。换句话说,当网络太容易重新创建输入时,它无法仅学习数据的最有趣的方面,并提高使用编码作为输入的机器学习模型的性能。

与其他具有给定任务的过度容量的模型一样,正则化可以帮助解决过拟合挑战,通过约束自编码器的学习过程并强制其产生有用的表示(例如,参见第七章线性模型-从风险因素到回报预测,关于线性模型的正则化,以及第十七章用于交易的深度学习,关于神经网络)。 理想情况下,我们可以将模型的容量精确匹配到数据分布的复杂性。 在实践中,最佳模型通常结合(有限的)过剩容量和适当的正则化。 为此,我们将一个依赖于编码层h的权重的稀疏度惩罚添加到训练目标中:

我们在本章稍后探讨的一种常见方法是使用L1 正则化,它在损失函数中添加了一种惩罚,即权重的绝对值之和。 L1 范数会导致稀疏编码,因为它会强制将参数的值设为零,如果它们不能捕获数据中的独立变化(参见第七章线性模型-从风险因素到回报预测)。 因此,即使是隐藏层维度比输入高的超完备自编码器也可能学会学习信号内容。

使用去噪自编码器修复损坏的数据

到目前为止,我们讨论的自编码器设计用于尽管容量有限但重现输入。 另一种方法是训练带有损坏输入的自编码器以输出所需的原始数据点。 在这种情况下,自编码器最小化损失L

损坏的输入是防止网络学习身份函数而不是从数据中提取信号或显著特征的另一种方法。 已经证明去噪自编码器学会了原始数据的数据生成过程,并且在生成建模中变得流行,其中目标是学习产生输入的概率分布(Vincent 等,2008)。

用于时间序列特征的 Seq2seq 自编码器

循环神经网络RNNs)已经发展用于具有数据点之间长期依赖关系的顺序数据,可能覆盖长距离(第十九章用于多元时间序列和情感分析的 RNNs)。 类似地,序列到序列(seq2seq)自编码器旨在学习适应序列生成数据性质的表示(Srivastava,Mansimov 和 Salakhutdinov,2016)。

Seq2seq 自编码器基于 RNN 组件,如长短期记忆LSTM)或门控循环单元。 它们学习顺序数据的表示,并已成功应用于视频,文本,音频和时间序列数据。

如上一章所述,编码器-解码器架构允许 RNN 处理具有可变长度的输入和输出序列。这些架构支撑了许多复杂序列预测任务的进展,如语音识别和文本翻译,并且越来越多地应用于(金融)时间序列。在高层次上,它们的工作原理如下:

  1. LSTM 编码器逐步处理输入序列以学习隐藏状态。

  2. 此状态成为序列的学习表示,以固定长度的向量形式呈现。

  3. LSTM 解码器接收此状态作为输入,并使用它来生成输出序列。

请参见 GitHub 上链接的参考示例,了解构建序列到序列自动编码器以压缩时间序列数据检测时间序列中的异常的示例,以便例如监管机构发现潜在的非法交易活动。

使用变分自动编码器进行生成建模

变分自动编码器VAE)是最近发展起来的(Kingma 和 Welling,2014),专注于生成建模。与给定数据学习预测器的判别模型相反,生成模型旨在解决更一般的问题,即学习所有变量的联合概率分布。如果成功,它可以模拟数据首次生成的方式。学习数据生成过程非常有价值:它揭示了潜在的因果关系,并支持半监督学习,以有效地从小型标记数据集推广到大型未标记数据集。

更具体地说,VAEs 旨在学习模型负责输入数据的潜在(意思是未观察到)变量。请注意,在第十五章主题建模 - 总结财务新闻第十六章用于盈利电话和 SEC 备案的词嵌入中,我们遇到了潜在变量。

就像迄今讨论的自动编码器一样,VAEs 不允许网络学习任意函数,只要它忠实地重现输入即可。相反,它们旨在学习生成输入数据的概率分布的参数。

换句话说,VAEs 是生成模型,因为如果成功,您可以通过从 VAE 学习的分布中抽样来生成新的数据点。

VAE 的操作比迄今讨论的自动编码器更复杂,因为它涉及随机反向传播,即对随机变量的导数,并且细节超出了本书的范围。它们能够学习没有正则化的高容量输入编码,这是有用的,因为模型旨在最大化训练数据的概率,而不是复制输入。有关详细介绍,请参见 Kingma 和 Welling(2019)。

variational_autoencoder.ipynb 笔记本包含了一个应用于时尚 MNIST 数据集的样本 VAE 实现,该实现改编自 François Chollet 的 Keras 教程,以适配 TensorFlow 2。GitHub 上链接的资源包含一个 VAE 教程,其中包含指向 PyTorch 和 TensorFlow 2 实现的参考资料以及许多其他参考文献。参见 Wang 等人(2019)的应用,该应用将 VAE 与使用 LSTM 的 RNN 结合起来,并在期货市场中表现出优于各种基准模型的效果。

使用 TensorFlow 2 实现自编码器

在本节中,我们将说明如何使用 TensorFlow 2 的 Keras 接口来实现上一节介绍的几种自编码器模型。我们首先加载和准备一个图像数据集,我们将在本节中始终使用该数据集。我们将使用图像而不是金融时间序列,因为这样更容易可视化编码过程的结果。下一节将说明如何将自编码器与金融数据结合起来,作为更复杂架构的一部分,该架构可以作为交易策略的基础。

准备好数据后,我们将继续构建使用深度前馈网络、稀疏约束和卷积的自编码器,并将后者应用于图像去噪。

如何准备数据

为了说明,我们将使用时尚 MNIST 数据集,这是由 Lecun 等人(1998)与 LeNet 结合使用的经典 MNIST 手写数字数据集的现代替代品。我们还在 第十三章使用无监督学习进行数据驱动风险因素和资产配置 中依赖于该数据集。

Keras 使得很容易访问具有分辨率为 28 × 28 像素的 60,000 个训练和 10,000 个测试灰度样本:

from tensorflow.keras.datasets import fashion_mnist
(X_train, y_train), (X_test, y_test) = fashion_mnist.load_data()
X_train.shape, X_test.shape
((60000, 28, 28), (10000, 28, 28)) 

数据包含来自 10 类的服装物品。图 20.2 绘制了每个类别的一个样本图像:

图 20.2:时尚 MNIST 样本图像

我们重新塑造数据,使每个图像由一个扁平的一维像素向量表示,其中有 28 × 28 = 784 个元素,规范化到 [0, 1] 范围内:

image_size = 28              # pixels per side
input_size = image_size ** 2 # 784
def data_prep(x, size=input_size):
    return x.reshape(-1, size).astype('float32')/255
X_train_scaled = data_prep(X_train)
X_test_scaled = data_prep(X_test)
X_train_scaled.shape, X_test_scaled.shape
((60000, 784), (10000, 784)) 

单层前馈自编码器

我们从一个具有单个隐藏层的普通前馈自编码器开始,以说明使用 Functional Keras API 的一般设计方法,并建立性能基线。

第一步是使用 784 个元素的扁平图像向量的占位符:

input_ = Input(shape=(input_size,), name='Input') 

模型的编码器部分由一个全连接层组成,用于学习输入的新压缩表示。我们使用 32 个单元,压缩比为 24.5:

encoding_size = 32 # compression factor: 784 / 32 = 24.5
encoding = Dense(units=encoding_size,
                 activation='relu',
                 name='Encoder')(input_) 

解码部分将压缩的数据一次性重构为其原始大小:

decoding = Dense(units=input_size,
                 activation='sigmoid',
                 name='Decoder')(encoding) 

我们使用链式输入和输出元素实例化 Model 类,这些元素隐含地定义了计算图,如下所示:

autoencoder = Model(inputs=input_,
                    outputs=decoding,
                    name='Autoencoder') 

因此,所定义的编码器-解码器计算使用了近 51,000 个参数:

Layer (type)                 Output Shape              Param #   
Input (InputLayer)           (None, 784)               0         
Encoder (Dense)              (None, 32)                25120     
Decoder (Dense)              (None, 784)               25872     
Total params: 50,992
Trainable params: 50,992
Non-trainable params: 0 

Functional API 允许我们使用模型链的部分作为单独的编码器和解码器模型,这些模型使用训练期间学到的自编码器的参数。

定义编码器

编码器仅使用输入和隐藏层,总参数约为一半:

encoder = Model(inputs=input_, outputs=encoding, name='Encoder')
encoder.summary()
Layer (type)                 Output Shape              Param #   
Input (InputLayer)           (None, 784)               0         
Encoder (Dense)              (None, 32)                25120     
Total params: 25,120
Trainable params: 25,120
Non-trainable params: 0 

不久我们将看到,一旦训练了自动编码器,我们就可以使用编码器来压缩数据。

定义解码器

解码器由最后一个自动编码器层组成,由编码数据的占位符提供:

encoded_input = Input(shape=(encoding_size,), name='Decoder_Input')
decoder_layer = autoencoder.layers-1
decoder = Model(inputs=encoded_input, outputs=decoder_layer)
decoder.summary()
Layer (type)                 Output Shape              Param #   
Decoder_Input (InputLayer)   (None, 32)                0         
Decoder (Dense)              (None, 784)               25872     
Total params: 25,872
Trainable params: 25,872
Non-trainable params: 0 

训练模型

我们编译模型以使用 Adam 优化器(参见第十七章交易的深度学习)来最小化输入数据和自动编码器实现的复制之间的均方误差。为了确保自动编码器学会复制输入,我们使用相同的输入和输出数据来训练模型:

autoencoder.compile(optimizer='adam', loss='mse')
autoencoder.fit(x=X_train_scaled, y=X_train_scaled,
                epochs=100, batch_size=32,
                shuffle=True, validation_split=.1,
                callbacks=[tb_callback, early_stopping, checkpointer]) 

评估结果

训练在一定的 20 个周期后停止,测试 RMSE 为 0.1121:

mse = autoencoder.evaluate(x=X_test_scaled, y=X_test_scaled)
f'MSE: {mse:.4f} | RMSE {mse**.5:.4f}'
'MSE: 0.0126 | RMSE 0.1121' 

要对数据进行编码,我们使用刚刚定义的编码器如下:

encoded_test_img = encoder.predict(X_test_scaled)
Encoded_test_img.shape
(10000, 32) 

解码器获取压缩数据,并根据自动编码器的训练结果重现输出:

decoded_test_img = decoder.predict(encoded_test_img)
decoded_test_img.shape
(10000, 784) 

图 20.3显示了 10 张原始图像及其经自动编码器重建后的图像,并展示了压缩后的损失:

图 20.3:示例时尚 MNIST 图像,原始和重建

带稀疏约束的前馈自动编码器

添加正则化相当简单。我们可以使用 Keras 的activity_regularizer将其应用于密集编码器层,如下所示:

encoding_l1 = Dense(units=encoding_size,
                    activation='relu',
                    activity_regularizer=regularizers.l1(10e-5),
                    name='Encoder_L1')(input_) 

输入和解码层保持不变。在这个例子中,压缩因子为 24.5,正则化对性能产生了负面影响,测试 RMSE 为 0.1229。

深度前馈自动编码器

为了说明向自动编码器添加深度的好处,我们将构建一个三层前馈模型,依次将输入从 784 压缩到 128、64 和 32 个单元:

input_ = Input(shape=(input_size,))
x = Dense(128, activation='relu', name='Encoding1')(input_)
x = Dense(64, activation='relu', name='Encoding2')(x)
encoding_deep = Dense(32, activation='relu', name='Encoding3')(x)
x = Dense(64, activation='relu', name='Decoding1')(encoding_deep)
x = Dense(128, activation='relu', name='Decoding2')(x)
decoding_deep = Dense(input_size, activation='sigmoid', name='Decoding3')(x)
autoencoder_deep = Model(input_, decoding_deep) 

结果模型有超过 222,000 个参数,比之前的单层模型的容量多四倍多:

Layer (type)                 Output Shape              Param #   
=================================================================
input_1 (InputLayer)         (None, 784)               0         
_________________________________________________________________
Encoding1 (Dense)            (None, 128)               100480    
_________________________________________________________________
Encoding2 (Dense)            (None, 64)                8256      
_________________________________________________________________
Encoding3 (Dense)            (None, 32)                2080      
_________________________________________________________________
Decoding1 (Dense)            (None, 64)                2112      
_________________________________________________________________
Decoding2 (Dense)            (None, 128)               8320      
_________________________________________________________________
Decoding3 (Dense)            (None, 784)               101136    
=================================================================
Total params: 222,384
Trainable params: 222,384
Non-trainable params: 0 

训练在 45 个周期后停止,并将测试 RMSE 减少了 14%,达到了 0.097。由于分辨率较低,难以明显注意到更好的重建。

可视化编码

我们可以使用流形学习技术t-分布随机邻域嵌入t-SNE;参见第十三章使用无监督学习的数据驱动风险因子和资产配置)来可视化和评估自动编码器隐藏层学习到的编码的质量。

如果编码成功捕捉到数据的显著特征,那么数据的压缩表示仍应显示与区分观察的 10 个类别对齐的结构。我们使用刚刚训练的深度编码器的输出来获取测试集的 32 维表示:

tsne = TSNE(perplexity=25, n_iter=5000)
train_embed = tsne.fit_transform(encoder_deep.predict(X_train_scaled)) 

图 20.4显示出 10 个类别很好地分离,表明编码对于作为保存数据关键特征的较低维度表示是有用的(请参见variational_autoencoder.ipynb笔记本以获取彩色版本):

图 20.4:Fashion MNIST 自编码器嵌入的 t-SNE 可视化

卷积自编码器

第十八章《金融时间序列和卫星图像的 CNN》,关于 CNN 的见解表明我们应该将卷积层合并到自编码器中,以提取具有图像数据网格结构特征的信息。

我们定义了一个三层编码器,它使用 32、16 和 8 个滤波器的 2D 卷积,分别使用 ReLU 激活和'same'填充以保持输入大小。第三层的结果编码大小为,比之前的示例要高:

x = Conv2D(filters=32,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_1')(input_)
x = MaxPooling2D(pool_size=(2, 2), padding='same', name='Encoding_Max_1')(x)
x = Conv2D(filters=16,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_2')(x)
x = MaxPooling2D(pool_size=(2, 2), padding='same', name='Encoding_Max_2')(x)
x = Conv2D(filters=8,
           kernel_size=(3, 3),
           activation='relu',
           padding='same',
           name='Encoding_Conv_3')(x)
encoded_conv = MaxPooling2D(pool_size=(2, 2),
                            padding='same',
                            name='Encoding_Max_3')(x) 

我们还定义了一个相匹配的解码器,它反转了滤波器数量,并使用 2D 上采样而不是最大池化来反转滤波器大小的减小。三层自编码器有 12,785 个参数,略多于深度自编码器容量的 5%。

训练在 67 个时代后停止,并导致测试 RMSE 进一步降低了 9%,这是由于卷积滤波器能够更有效地从图像数据中学习以及较大的编码大小的能力的结合。

去噪自编码器

自编码器应用于去噪任务只影响训练阶段。在本例中,我们向 Fashion MNIST 数据添加了来自标准正态分布的噪声,同时保持像素值在[0, 1]范围内,如下所示:

def add_noise(x, noise_factor=.3):
    return np.clip(x  + noise_factor * np.random.normal(size=x.shape), 0, 1)
X_train_noisy = add_noise(X_train_scaled)
X_test_noisy = add_noise(X_test_scaled) 

然后,我们继续在嘈杂的输入上训练卷积自编码器,目标是学习如何生成未损坏的原始图像:

autoencoder_denoise.fit(x=X_train_noisy,
                        y=X_train_scaled,
                        ...) 

60 个时代后的测试 RMSE 为 0.0931,毫不奇怪地比以前高。图 20.5显示,从上到下,原始图像以及嘈杂和去噪版本。它说明了自编码器成功地从嘈杂的图像中产生了与从原始图像中产生的相似的压缩编码:

图 20.5:去噪输入和输出示例

用于交易的有条件自编码器

Gu、Kelly 和 Xiu(GKX,2019)最近的研究开发了一种基于证券对风险因素的暴露的资产定价模型。当我们在第十三章《数据驱动的风险因素和无监督学习的资产配置》中介绍 PCA 以及在第四章《金融特征工程-如何研究 Alpha 因子》中介绍的风险因素模型时,它建立在我们讨论的数据驱动风险因素概念上。他们的目标是表明因子模型用于捕捉“异常”系统驱动因素的资产特征只是无法直接测量的风险因素的时间变化暴露的代理。在这种情况下,“异常”是超过由暴露于总体市场风险的回报(请参阅第五章《投资组合优化和绩效评估》中对资本资产定价模型的讨论)所解释的回报。

第四章第七章讨论的Fama-French 因子模型通过指定风险因素如公司规模来解释回报,基于对超过聚合市场风险所致平均股票回报的经验观察。鉴于这些特定的风险因素,这些模型能够通过相应设计的组合度量投资者为承担因子风险而获得的回报:按规模分类股票,购买最小的五分位数,卖出最大的五分位数,并计算回报。观察到的风险因素回报然后允许线性回归估计资产对这些因子的敏感性(称为因子加载),从而有助于基于(较少的)因子回报的预测来预测(许多)资产的回报。

相比之下,GKX 将风险因素视为潜在的或不可观察的,在许多资产之间驱动协方差,从而阻止投资者通过分散化来避免暴露。因此,投资者需要一种调整的回报,就像任何价格一样来实现均衡,进而提供不再异常的回报差异的经济合理性。在这种观点中,风险因素纯粹是统计性质的,而潜在的经济力量可以是任意的和多变的来源。

在另一篇最近的论文中(Kelly, Pruitt, and Su, 2019),Kelly——在耶鲁大学教授金融学,与 AQR 合作,并是将机器学习应用于交易的先驱之一——及其合著者开发了一个线性模型称为工具化主成分分析(IPCA),以从数据中估计潜在的风险因素和资产的因子加载。 IPCA 将 PCA 扩展到包括资产特征作为协变量,并产生时变的因子加载。通过将资产暴露于可观察的资产特征的因素上,IPCA 的目标是回答是否有一组共同的潜在风险因素来解释观察到的异常,而不是是否有一个特定的可观察因素可以这样做。

GKX 创建了一个条件自编码器架构,以反映线性 Fama-French 模型和 IPCA 方法所忽略的回报动态的非线性性质。结果是一个深度神经网络,它同时使用自编码器学习给定数量的不可观测因素的溢价,并使用前馈网络基于广泛的时间变化资产特征学习大量股票的因子加载。该模型成功地解释和预测资产回报。它展示了一个在统计上和经济上都显著的关系,当转化为类似于我们在本书中使用的例子的长短十分位差异策略时,产生了具有吸引力的夏普比率。

在本节中,我们将创建这个模型的简化版本,以演示如何利用自动编码器生成可交易的信号。为此,我们将使用 yfinance 在 1990 年至 2019 年期间构建一个接近 4,000 只美国股票的新数据集,因为它提供了一些额外的信息,有助于计算资产特征。我们会采取一些捷径,比如使用较少的资产和仅最重要的特征。我们还会省略一些实现细节,以简化表达。我们将重点介绍最重要的差异,以便您相应地增强模型。

我们将首先展示如何准备数据,然后解释、构建和训练模型并评估其预测性能。请参阅上述参考资料以了解更多有关理论和实现的背景。

获取股票价格和元数据信息

GKX 参考实现使用了来自 1957 年至 2016 年的中心研究安全价格(CRSP)的超过 30,000 只美国股票的股价和公司特征数据,频率为每月一次。它计算了 94 个指标,其中包括了一系列在以往学术研究中被建议用于预测收益的资产属性,这些属性在 Green、Hand 和 Zhang(2017 年)中列出,他们旨在验证这些说法。

由于我们无法获得高质量但昂贵的 CRSP 数据,我们利用了 yfinance(见第二章市场和基本数据-来源和技术)从 Yahoo Finance 下载价格和元数据。选择免费数据有一些缺点,包括:

  • 缺乏关于调整的质量控制

  • 存活偏差,因为我们无法获取不再上市的股票的数据

  • 在股票数量和历史长度方面范围较小

build_us_stock_dataset.ipynb 笔记本包含了本节的相关代码示例。

要获取数据,我们使用 pandas-datareader(见第二章市场和基本数据-来源和技术)从 NASDAQ 获取了 8,882 个当前交易符号的列表:

from pandas_datareader.nasdaq_trader import get_nasdaq_symbols
traded_symbols = get_nasdaq_symbols() 

我们移除 ETF,并为其余部分创建 yfinance Ticker()对象。

import yfinance as yf
tickers = yf.Tickers(traded_symbols[~traded_symbols.ETF].index.to_list()) 

每个股票的.info属性包含从 Yahoo Finance 抓取的数据点,从未偿还的股票数量和其他基本面到最新的市值;覆盖范围因证券而异:

info = []
for ticker in tickers.tickers:
    info.append(pd.Series(ticker.info).to_frame(ticker.ticker))
info = pd.concat(info, axis=1).dropna(how='all').T
info = info.apply(pd.to_numeric, errors='ignore') 

对于具有元数据的股票,我们同时下载调整后和未调整的价格,后者包括像股票分割和股息支付之类的企业行动,我们可以使用这些信息创建一个 Zipline bundle 用于策略回测(见第八章ML4T 工作流程-从模型到策略回测)。

我们获得了 4,314 支股票的调整后的 OHLCV 数据,具体如下:

prices_adj = []
with pd.HDFStore('chunks.h5') as store:
    for i, chunk in enumerate(chunks(tickers, 100)):
        print(i, end=' ', flush=True)
        prices_adj.append(yf.download(chunk,
                                      period='max',
                                      auto_adjust=True).stack(-1))
prices_adj = (pd.concat(prices_adj)
              .dropna(how='all', axis=1)
              .rename(columns=str.lower)
              .swaplevel())
prices_adj.index.names = ['ticker', 'date'] 

在没有关于基础价格数据和股票分割调整的任何质量控制的情况下,我们删除了具有可疑值的股票,如日回报超过 100%或低于-100%的股票:

df = prices_adj.close.unstack('ticker')
pmax = df.pct_change().max()
pmin = df.pct_change().min()
to_drop = pmax[pmax > 1].index.union(pmin[pmin<-1].index) 

这样做会移除大约 10%的股票,使我们在 1990-2019 年期间拥有接近 3900 个资产。

计算预测性资产特征

GKX 根据 Green 等人(2017 年)测试了 94 个资产属性,并确定了 20 个最具影响力的指标,同时断言特征重要性随后迅速下降。这 20 个最重要的股票特征分为三类,即:

  • 价格趋势,包括(行业)动量、短期和长期逆转,或最近的最大收益

  • 流动性,例如周转率、美元成交量或市值

  • 风险测量,例如,总体和特异性回报波动率或市场贝塔

在这 20 个指标中,我们将分析限制在 16 个指标上,这些指标我们已经或者可以近似计算相关的输入。 conditional_autoencoder_for_trading_data.ipynb 笔记本演示了如何计算相关指标。我们在本节中突出了一些例子;另请参阅附录Alpha 因子库

一些指标需要诸如行业、市值和流通股数等信息,因此我们将我们的股价数据集限制在具有相关元数据的证券上:

tickers_with_metadata = (metadata[metadata.sector.isin(sectors) & 
                                 metadata.marketcap.notnull() &
                                 metadata.sharesoutstanding.notnull() & 
                                (metadata.sharesoutstanding > 0)]
                                 .index.drop(tickers_with_errors)) 

我们以每周而不是每月的回报频率运行我们的分析,以弥补时间周期减少 50%和股票数量减少约 80%的情况。我们得到的每周回报如下所示:

returns = (prices.close
           .unstack('ticker')
           .resample('W-FRI').last()
           .sort_index().pct_change().iloc[1:]) 

大多数指标都相当容易计算。股票动量,即截止到当前日期前 1 个月的 11 个月累积股票回报,可以如下推导:

MONTH = 21
mom12m = (close
            .pct_change(periods=11 * MONTH)
            .shift(MONTH)
            .resample('W-FRI')
            .last()
            .stack()
            .to_frame('mom12m')) 

Amihud 流动性测量是股票绝对收益与其交易额的比率,以滚动的 21 天平均值表示:

dv = close.mul(volume)
ill = (close.pct_change().abs()
       .div(dv)
       .rolling(21)
       .mean()
       .resample('W-FRI').last()
       .stack()
       .to_frame('ill')) 

特异性波动性被测量为最近三年等权重市场指数收益的回归残差的标准差。我们使用statsmodels进行这个计算密集型指标的计算:

index = close.resample('W-FRI').last().pct_change().mean(1).to_frame('x')
def get_ols_residuals(y, x=index):
    df = x.join(y.to_frame('y')).dropna()
    model = sm.OLS(endog=df.y, exog=sm.add_constant(df[['x']]))
    result = model.fit()
    return result.resid.std()
idiovol = (returns.apply(lambda x: x.rolling(3 * 52)
                         .apply(get_ols_residuals))) 

对于市场贝塔,我们可以使用 statsmodels 的RollingOLS类,周资产收益作为结果,等权重指数作为输入:

def get_market_beta(y, x=index):
    df = x.join(y.to_frame('y')).dropna()
    model = RollingOLS(endog=df.y, 
                       exog=sm.add_constant(df[['x']]),
                       window=3*52)
    return model.fit(params_only=True).params['x']
beta = (returns.dropna(thresh=3*52, axis=1)
        .apply(get_market_beta).stack().to_frame('beta')) 

我们最终在 1990-2019 年期间的大约 3800 个证券上得到了约 1600 万次观察结果。图 20.6显示了每周股票收益数量的直方图(左侧面板)和每种特征的观察数量分布的箱线图:

图 20.6:随时间和每种 - 股票特征的股票数目

为了限制离群值的影响,我们遵循 GKX 并对特征进行等级标准化,使其落在[-1, 1]的区间内:

data.loc[:, characteristics] = (data.loc[:, characteristics]
                                .groupby(level='date')
                                .apply(lambda x:
                                      pd.DataFrame(quantile_transform(
                                      x, 
                                      copy=True, 
                                      n_quantiles=x.shape[0]),
                                      columns=characteristics,
                                        index=x.index.get_level_values('ticker'))
                                      )
                               .mul(2).sub(1)) 

由于神经网络无法处理缺失数据,我们将缺失值设置为-2,该值位于每周收益和特征的范围之外。

作者采用额外的方法来避免过度权重的小市值股票,例如市值加权最小二乘回归。他们还通过考虑特征的保守报告滞后来调整数据窥探偏差。

创建条件自编码器架构

GKX 提出的条件自编码器允许考虑到变化的资产特征的时变回报分布。为此,作者将我们在本章第一节中讨论的标准自编码器架构扩展,以允许特征来塑造编码。

图 20.7说明了该架构将结果(资产回报,顶部)建模为资产特征(左侧输入)和再次个体资产回报(右侧输入)的函数。作者允许资产回报是个体股票回报或根据资产特征从样本中的股票组成的组合,类似于我们在第四章中讨论的法玛-法 rench 因子组合投资组合,并在本节的介绍中总结(因此从股票到组合的虚线)。我们将使用个体股票回报;有关使用组合而不是个体股票的详细信息,请参阅 GKX。

图 20.7:GKX 设计的条件自编码器架构

左侧的前馈神经网络模拟了作为其P特征(输入)的函数的N个个体股票的K因子载荷(beta 输出)。在我们的案例中,N约为 3,800,P等于 16。作者尝试了最多三个隐藏层,分别具有 32、16 和 8 个单元,并发现两个层表现最佳。由于特征数量较少,我们仅使用了一个类似的层,并发现 8 个单元最有效。

当以个体资产回报作为输入时,该架构的右侧是传统的自编码器,因为它将N个资产回报映射到它们自己。作者以这种方式使用它来衡量导出的因子如何解释同时发生的回报。此外,他们使用自编码器通过使用来自期间t-1 的输入回报和期间t的输出回报来预测未来回报。我们将重点关注该架构用于预测的用途,强调自编码器是本章第一节中提到的前馈神经网络的特殊情况。

模型输出是左侧的因子载荷与右侧的因子溢价的点积。作者在范围为 2-6 的K值上进行了实验,与已建立的因子模型类似。

要使用 TensorFlow 2 创建此架构,我们使用 Functional Keras API 并定义一个make_model()函数,该函数自动化了模型编译过程如下:

def make_model(hidden_units=8, n_factors=3):
    input_beta = Input((n_tickers, n_characteristics), name='input_beta')
    input_factor = Input((n_tickers,), name='input_factor')
    hidden_layer = Dense(units=hidden_units,
                         activation='relu',
                         name='hidden_layer')(input_beta)
    batch_norm = BatchNormalization(name='batch_norm')(hidden_layer)

    output_beta = Dense(units=n_factors, name='output_beta')(batch_norm)
    output_factor = Dense(units=n_factors,
                          name='output_factor')(input_factor)
    output = Dot(axes=(2,1),
                 name='output_layer')([output_beta, output_factor])
    model = Model(inputs=[input_beta, input_factor], outputs=output)
    model.compile(loss='mse', optimizer='adam')
    return model 

我们遵循作者的做法,使用批量归一化并编译模型以在此回归任务中使用均方误差和 Adam 优化器。该模型有 12,418 个参数(请参阅笔记本)。

作者使用了额外的正则化技术,例如对网络权重的 L1 惩罚,并结合了具有相同架构但使用不同随机种子的各种网络的结果。他们还使用了提前停止。

我们使用 20 年的数据进行交叉验证,用五个对应于 2015-2019 年的折叠来预测下一年的每周回报。我们通过计算验证集的信息系数(IC)来评估从 2 到 6 个因子 K 和 8、16 或 32 个隐藏层单元的组合:

factor_opts = [2, 3, 4, 5, 6]
unit_opts = [8, 16, 32]
param_grid = list(product(unit_opts, factor_opts))
for units, n_factors in param_grid:
    scores = []
    model = make_model(hidden_units=units, n_factors=n_factors)
    for fold, (train_idx, val_idx) in enumerate(cv.split(data)):
        X1_train, X2_train, y_train, X1_val, X2_val, y_val = \
            get_train_valid_data(data, train_idx, val_idx)
        for epoch in range(250):         
            model.fit([X1_train, X2_train], y_train,
                      batch_size=batch_size,
                      validation_data=([X1_val, X2_val], y_val),
                      epochs=epoch + 1,
                      initial_epoch=epoch, 
                      verbose=0, shuffle=True)
            result = (pd.DataFrame({'y_pred': model.predict([X1_val,
                                                             X2_val])
                                   .reshape(-1),
                                    'y_true': y_val.stack().values},
                                  index=y_val.stack().index)
                      .replace(-2, np.nan).dropna())
            r0 = spearmanr(result.y_true, result.y_pred)[0]
            r1 = result.groupby(level='date').apply(lambda x: 
                                                    spearmanr(x.y_pred, 
                                                              x.y_true)[0])
            scores.append([units, n_factors, fold, epoch, r0, r1.mean(),
                           r1.std(), r1.median()]) 

图 20.8 绘制了五因子计数和三种隐藏层大小组合在五个年度交叉验证折叠中每个时期的验证 IC 的平均值。上面的面板显示了 52 周的 IC,下面的面板显示了平均每周的 IC(有关彩色版本,请参阅笔记本):

图 20.8:所有因子和隐藏层大小组合的交叉验证性能

结果表明,更多的因子和较少的隐藏层单元效果更好;特别是,具有八个单元的四和六个因子在 0.02-0.03 的整体 IC 值范围内表现最佳。

为了评估模型预测性能的经济意义,我们生成了一个具有八个单元的四因子模型的预测,训练了 15 个周期。然后,我们使用 Alphalens 来计算预测的五分位等权重投资组合之间的价差,同时忽略交易成本(请参阅 alphalens_analysis.ipynb 笔记本)。

图 20.9 显示了持有期从 5 天到 21 天的平均价差。对于较短的期限,这也反映了预测视野,底部和顶部十分位数之间的价差约为 10 个基点:

图 20.9:预测五分位的平均期间差异

为了评估预测性能如何随时间转化为回报,我们绘制了类似投资组合的累积回报图,以及分别投资于前半部分和后半部分的多空投资组合的累积回报:

图 20.10:基于五分位和多空组合的累积回报

结果显示了五分位投资组合之间的显著差距,以及长期以来更广泛的多空投资组合的正累积回报。这支持了条件自编码器模型可能有助于盈利交易策略的假设。

学到的经验和下一步计划

条件自编码器结合了我们在第十三章《数据驱动的风险因素和使用无监督学习进行资产配置》中使用 PCA 探索的数据驱动风险因素的非线性版本,以及在第四章第七章中讨论的建模回报的风险因素方法。它说明了深度神经网络架构如何能够灵活适应各种任务,以及自编码器和前馈神经网络之间的流动边界。

从数据源到架构的众多简化指向了几个改进途径。除了获取更多质量更好的数据,并且还可以计算出额外特征的数据外,以下修改是一个起点——当然还有许多其他途径:

  • 尝试与周频以外的数据频率,以及年度以外的预测时段,其中较短的周期还将增加训练数据的数量。

  • 修改模型架构,特别是在使用更多数据的情况下,可能会推翻这样一个发现:一个更小的隐藏层会更好地估计因子载荷。

摘要

在本章中,我们介绍了无监督学习如何利用深度学习。自动编码器学习复杂的、非线性的特征表示,能够显著压缩复杂数据而损失很少信息。因此,它们对于应对与具有许多特征的丰富数据相关的维度灾难特别有用,尤其是具有替代数据的常见数据集。我们还看到如何使用 TensorFlow 2 实现各种类型的自动编码器。

最重要的是,我们实现了最近的学术研究,从数据中提取数据驱动的风险因素来预测回报。与我们在第十三章中对这一挑战的线性方法不同,基于数据驱动的风险因素和无监督学习的资产配置,自动编码器捕捉非线性关系。此外,深度学习的灵活性使我们能够将许多关键资产特征纳入模型中,以建模更敏感的因素,有助于预测回报。

在下一章中,我们将重点研究生成对抗网络,它们常被称为人工智能最令人兴奋的最新发展之一,并看看它们如何能够创建合成的训练数据。

第二十一章:用于合成时间序列数据的生成对抗网络

在上一章关于自编码器的介绍之后,本章将介绍第二个无监督深度学习技术:生成对抗网络GANs)。与自编码器一样,GANs 补充了第十三章介绍的降维和聚类方法,即*《基于数据的风险因素和无监督学习的资产配置》*。

GANs 是由 Goodfellow 等人于 2014 年发明的。Yann LeCun 称 GANs 是“过去十年中人工智能中最激动人心的想法。” 一个 GAN 在竞争环境中训练两个神经网络,称为生成器判别器。生成器旨在生成使判别器无法与给定类别的训练数据区分的样本。其结果是一种生成模型,能够产生代表某个特定目标分布的合成样本,但是这些样本是人工生成的,因此成本较低。

GANs 在许多领域产生了大量的研究和成功的应用。虽然最初应用于图像,但 Esteban、Hyland 和 Rätsch(2017)将 GANs 应用于医学领域以生成合成时间序列数据。随后进行了与金融数据的实验(Koshiyama、Firoozye 和 Treleaven 2019;Wiese 等人 2019;Zhou 等人 2018;Fu 等人 2019),以探索 GANs 是否能够生成模拟替代资产价格轨迹的数据,以训练监督或强化算法,或进行交易策略的回测。我们将复制 2019 年 NeurIPS 由 Yoon、Jarrett 和 van der Schaar(2019)提出的时间序列 GAN,以说明该方法并展示结果。

具体来说,在本章中,您将学习以下内容:

  • GANs 的工作原理、其用处以及如何应用于交易

  • 使用 TensorFlow 2 设计和训练 GANs

  • 生成合成金融数据以扩展用于训练 ML 模型和回测的输入

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

使用 GANs 创建合成数据

本书主要关注接收输入数据并预测结果的监督学习算法,我们可以将其与基本事实进行比较以评估其性能。这样的算法也称为判别模型,因为它们学会区分不同的输出值。

GANs 是像我们在上一章遇到的变分自编码器那样的生成模型的一个实例。如前所述,生成模型使用从某个分布 p[data] 中抽取的样本的训练集,并学习表示该数据生成分布的估计 p[model]。

正如介绍中提到的,GAN 被认为是最近最激动人心的机器学习创新之一,因为它们似乎能够生成高质量的样本,忠实地模仿一系列输入数据。这在需要监督学习所需的标记数据缺失或成本过高的情况下非常具有吸引力。

GAN(生成对抗网络)引发了一波研究热潮,最初集中于生成惊人逼真的图像。最近,出现了产生合成时间序列的 GAN 实例,这对于交易具有重要潜力,因为历史市场数据的有限可用性是回测过拟合风险的主要驱动因素。

在本节中,我们将更详细地解释生成模型和对抗训练的工作原理,并审查各种 GAN 架构。在下一节中,我们将演示如何使用 TensorFlow 2 设计和训练 GAN。在最后一节中,我们将描述如何调整 GAN,使其生成合成时间序列数据。

比较生成模型和判别模型

判别模型学习如何区分在给定输入数据X的情况下的不同结果y。换句话说,它们学习给定数据的结果的概率:p(y | X)。另一方面,生成模型学习输入和结果的联合分布p(y, X)。虽然生成模型可以使用贝叶斯定理作为判别模型来计算哪个类别最有可能(参见第十章贝叶斯机器学习 - 动态夏普比率和对冲交易),但通常似乎更可取地直接解决预测问题,而不是先解决更一般的生成挑战(Ng 和 Jordan,2002)。

GAN 具有生成目标:它们生成复杂的输出,例如逼真的图像,给定甚至可以是随机数的简单输入。它们通过对可能输出的概率分布进行建模来实现这一点。这个概率分布可以有很多维度,例如图像中的每个像素,文档中的每个字符或标记,或者时间序列中的每个值。因此,模型可以生成很可能代表输出类别的输出。

理查德·费曼的引述“我无法创建的,我就无法理解”强调了对建模生成分布的重要性,这是迈向更一般人工智能的重要一步,类似于人类学习,后者使用更少的样本就能成功。

生成模型除了能够从给定分布生成额外样本之外,还有几个用例。例如,它们可以被纳入基于模型的强化学习RL)算法中(请参见下一章)。生成模型也可以应用于时间序列数据,以模拟可供规划在 RL 或监督学习中使用的备选过去或可能的未来轨迹,包括用于设计交易算法。其他用例包括半监督学习,其中 GAN 可以通过特征匹配来为缺失标签分配比当前方法少得多的训练样本。

对抗训练 - 一个零和游戏的欺诈行为

GANs 的关键创新是学习数据生成概率分布的新方法。该算法建立了两个神经网络之间的竞争性或对抗性游戏,称为生成器鉴别器

生成器的目标是将随机噪声输入转换成特定类别对象的虚假实例,例如人脸图像或股票价格时间序列。鉴别器则旨在将生成器的欺骗性输出与包含目标对象真实样本的训练数据集区分开来。整个 GAN 的目标是使两个网络在各自的任务上变得更好,以便生成器产生的输出机器无法再与原始数据区分开来(在此时我们不再需要鉴别器,因为它不再必要,可以丢弃它)。

图 21.1说明了使用通用 GAN 架构进行对抗训练,该架构旨在生成图像。我们假设生成器使用深度 CNN 架构(例如我们在上一章讨论的卷积自动编码器中的 VGG16 示例),它像我们之前讨论的卷积自动编码器的解码器部分一样被反转。生成器接收具有随机像素值的输入图像,并产生传递给鉴别器网络的输出图像,鉴别器网络使用镜像 CNN 架构。鉴别器网络还接收代表目标分布的真实样本,并预测输入是真实还是伪造的概率。学习通过将鉴别器和生成器损失的梯度反向传播到各自网络的参数来进行:

图 21.1:GAN 架构

最近的 GAN 实验室是一个很棒的交互式工具,灵感来自 TensorFlow Playground,它允许用户设计 GAN 并可视化学习过程和性能随时间的各个方面(请参见 GitHub 上的资源链接)。

GAN 架构动物园的快速演变

自 2014 年 Goodfellow 等人发表论文以来,GANs 吸引了大量关注,并引发了相应的研究热潮。

大部分工作是将原始架构进行细化,以适应不同的领域和任务,并扩展以包含额外的信息并创建条件生成对抗网络。额外的研究集中在改进这个具有挑战性的训练过程的方法上,该过程需要在两个网络之间实现稳定的博弈均衡,而每个网络都可能很难单独训练。

生成对抗网络的应用领域已经变得更加多样化,超出了我们在这里可以覆盖的范围;请参阅 Creswell 等人(2018 年)和 Pan 等人(2019 年)的最新调查,以及 Odena(2019 年)的未解问题清单。

深度卷积生成对抗网络用于表示学习

深度卷积生成对抗网络DCGANs)受到了卷积神经网络成功应用于网格数据的监督学习的启发(Radford,Metz 和 Chintala,2016)。该架构通过开发基于对抗训练的特征提取器将生成对抗网络应用于无监督学习,更易于训练并生成质量更高的图像。现在被认为是基线实现,有大量的开源示例可用(请参阅 GitHub 上的参考资料)。

一个 DCGAN 网络以均匀分布的随机数作为输入,并输出分辨率为 64×64 像素的彩色图像。随着输入的逐渐变化,生成的图像也会随之变化。该网络由标准的卷积神经网络组件组成,包括反卷积层,这些层与上一章节中的卷积自编码器示例中的卷积层相反,或者全连接层。

作者进行了详尽的实验,并提出了一些建议,例如在两个网络中都使用批标准化和 ReLU 激活。我们将在本章后面探讨 TensorFlow 的实现。

用于图像到图像转换的条件生成对抗网络

条件生成对抗网络cGANs)将附加的标签信息引入训练过程中,从而提高了输出的质量,并且能对输出进行一定程度的控制。

cGANs 通过向鉴别器添加第三个输入改变了之前显示的基线架构,该输入包含类别标签。例如,这些标签在生成图像时可以传达性别或头发颜色信息。

扩展包括生成对抗性的何处网络GAWWN;Reed 等,2016),它不仅使用边界框信息生成合成图像,还将物体放置在给定位置。

生成对抗网络应用于图像和时间序列数据

除了对原始架构进行大量的扩展和修改之外,还出现了许多应用于图像以及序列数据(如语音和音乐)的应用。图像应用特别多样,从图像混合和超分辨率到视频生成和人体姿势识别等。此外,生成对抗网络已被用于提高监督学习的性能。

我们将看一些显著的例子,然后更仔细地研究可能与算法交易和投资特别相关的时间序列数据应用。参见 Alqahtani,Kavakli-Thorne 和 Kumar(2019)进行最近调查,并参考 GitHub 引用获取额外资源。

CycleGAN – 无配对图像到图像的翻译

监督图像到图像的翻译旨在学习对齐的输入和输出图像之间的映射关系。当无配对图像可用时,CycleGAN 解决了这个任务,并将图像从一个域转换为匹配另一个域。

流行的例子包括将马的“绘画”合成为斑马,反之亦然。它还包括通过从任意风景照片生成印象派印刷的逼真样本(Zhu 等,2018 年)来转换风格。

StackGAN – 文本到照片图像合成

GAN 早期应用之一是根据文本生成图像。 堆叠 GAN,通常简称为StackGAN,使用句子作为输入,并生成与描述匹配的多个图像。

该架构分为两个阶段,第一阶段产生形状和颜色的低分辨率草图,第二阶段将结果增强为具有照片逼真细节的高分辨率图像(Zhang 等,2017 年)。

SRGAN – 照片逼真的单图像超分辨率

超分辨率旨在从低分辨率输入产生更高分辨率的逼真图像。应用于此任务的 GAN 具有深度 CNN 架构,使用批归一化,ReLU 和跳跃连接,如 ResNet 中所遇到的,以产生令人印象深刻的结果,这些结果已经找到商业应用(Ledig 等,2017 年)。

使用递归条件 GANs 合成合成时间序列

递归 GANsRGANs)和递归条件 GANsRCGANs)是两种旨在合成逼真的实值多变量时间序列的模型架构(Esteban,Hyland 和 Rätsch,2017)。作者针对医疗领域的应用,但该方法可能非常有价值,可以克服历史市场数据的限制。

RGANs 依赖于递归神经网络RNNs)作为生成器和鉴别器。 RCGANs 根据 cGANs 的精神添加辅助信息(参见前面的图像到图像的有条件 GAN部分)。

作者成功生成了视觉上和数量上令人信服的逼真样本。此外,他们通过使用合成数据来训练模型来评估合成数据的质量,包括合成标签,在真实测试集上预测性能只有轻微下降。作者还演示了成功应用 RCGANs 到一个早期预警系统,使用了一份来自重症监护病房的 17,000 名患者的医疗数据集。因此,作者阐明了 RCGANs 能够生成对监督训练有用的时间序列数据。我们将在本章的TimeGAN – 对合成金融数据进行对抗训练部分中应用这种方法到金融市场数据。

如何使用 TensorFlow 2 构建 GAN

为了说明使用 Python 实现 GAN,我们将使用本节早期讨论的 DCGAN 示例来合成来自 Fashion-MNIST 数据集的图像,我们在第十三章使用无监督学习进行数据驱动风险因子和资产配置中首次遇到该数据集。

有关实现细节和参考,请参见笔记本 deep_convolutional_generative_adversarial_network

构建生成器网络

生成器和判别器都使用类似图 20.1所示的深度 CNN 架构,但层数较少。生成器使用一个全连接输入层,然后是三个卷积层,如下所定义的 build_generator() 函数所示,该函数返回一个 Keras 模型实例:

def build_generator():
    return Sequential([Dense(7 * 7 * 256, 
                             use_bias=False,
                             input_shape=(100,), 
                             name='IN'),
                       BatchNormalization(name='BN1'),
                       LeakyReLU(name='RELU1'),
                       Reshape((7, 7, 256), name='SHAPE1'),
                       Conv2DTranspose(128, (5, 5), 
                                       strides=(1, 1),
                                       padding='same', 
                                       use_bias=False,
                                       name='CONV1'),
                       BatchNormalization(name='BN2'),
                       LeakyReLU(name='RELU2'),
                       Conv2DTranspose(64, (5, 5), 
                                       strides=(2, 2),
                                       padding='same',
                                       use_bias=False,
                                       name='CONV2'),
                       BatchNormalization(name='BN3'),
                       LeakyReLU(name='RELU3'),
                       Conv2DTranspose(1, (5, 5), 
                                       strides=(2, 2),
                                       padding='same', 
                                       use_bias=False,
                                       activation='tanh', 
                                       name='CONV3')],
                      name='Generator') 

生成器接受 100 个一维随机值作为输入,并产生宽高为 28 像素的图像,因此包含 784 个数据点。

对此函数返回的模型调用 .summary() 方法显示,该网络有超过 2.3 百万个参数(有关详细信息,请参见笔记本,包括训练前生成器输出的可视化)。

创建判别器网络

判别器网络使用两个卷积层将来自生成器的输入转换为单个输出值。该模型有大约 212,000 个参数:

def build_discriminator():
    return Sequential([Conv2D(64, (5, 5), 
                              strides=(2, 2), 
                              padding='same',
                              input_shape=[28, 28, 1], 
                              name='CONV1'),
                       LeakyReLU(name='RELU1'),
                       Dropout(0.3, name='DO1'),
                       Conv2D(128, (5, 5), 
                              strides=(2, 2),
                              padding='same', 
                              name='CONV2'),
                       LeakyReLU(name='RELU2'),
                       Dropout(0.3, name='DO2'),
                       Flatten(name='FLAT'),
                       Dense(1, name='OUT')],
                      name='Discriminator') 

图 21.2 描述了随机输入是如何从生成器流向判别器的,以及各个网络组件的输入和输出形状:

图 21.2: DCGAN TensorFlow 2 模型架构

设置对抗训练过程

现在我们已经构建了生成器和判别器模型,我们将设计并执行对抗训练过程。为此,我们将定义以下内容:

  • 反映它们之间竞争性互动的两个模型的损失函数

  • 运行反向传播算法的单个训练步骤

  • 训练循环重复训练步骤,直到模型性能符合我们的期望

定义生成器和判别器损失函数

生成器损失反映了鉴别器对假输入的决定。如果鉴别器误将生成器生成的图像误认为是真实图像,则生成器损失会很低;反之,则会很高;在创建训练步骤时,我们将定义这两个模型之间的交互。

生成器损失由二元交叉熵损失函数度量,如下所示:

cross_entropy = BinaryCrossentropy(from_logits=True)
def generator_loss(fake_output):
    return cross_entropy(tf.ones_like(fake_output), fake_output) 

鉴别器接收真实图像和假图像作为输入。它为每个图像计算损失,并试图通过最小化这两种类型输入的总和来准确识别它们:

def discriminator_loss(true_output, fake_output):
    true_loss = cross_entropy(tf.ones_like(true_output), true_output)
    fake_loss = cross_entropy(tf.zeros_like(fake_output), fake_output)
    return true_loss + fake_loss 

为了训练这两个模型,我们为每个模型分配了一个 Adam 优化器,其学习率低于默认值:

gen_optimizer = Adam(1e-4)
dis_optimizer = Adam(1e-4) 

核心 - 设计训练步骤

每个训练步骤实现一轮随机梯度下降,使用 Adam 优化器。它包括五个步骤:

  1. 向每个模型提供小批量输入

  2. 获取当前权重的模型输出

  3. 根据模型的目标和输出计算损失

  4. 根据每个模型权重的损失获取梯度

  5. 根据优化器的算法应用梯度

函数train_step()执行这五个步骤。我们使用@tf.function装饰器通过将其编译为 TensorFlow 操作来加速执行,而不是依赖急切执行(有关详细信息,请参阅 TensorFlow 文档):

@tf.function
def train_step(images):
    # generate the random input for the generator
    noise = tf.random.normal([BATCH_SIZE, noise_dim])
    with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape:     
        # get the generator output
        generated_img = generator(noise, training=True)
        # collect discriminator decisions regarding real and fake input
        true_output = discriminator(images, training=True)
        fake_output = discriminator(generated_img, training=True)
        # compute the loss for each model
        gen_loss = generator_loss(fake_output)
        disc_loss = discriminator_loss(true_output, fake_output)
    # compute the gradients for each loss with respect to the model variables
    grad_generator = gen_tape.gradient(gen_loss,
                                       generator.trainable_variables)
    grad_discriminator = disc_tape.gradient(disc_loss,
                                            discriminator.trainable_variables)
    # apply the gradient to complete the backpropagation step
    gen_optimizer.apply_gradients(zip(grad_generator,
                                      generator.trainable_variables))
    dis_optimizer.apply_gradients(zip(grad_discriminator,
                                      discriminator.trainable_variables)) 

组合在一起 - 训练循环

一旦我们正确定义了训练步骤,实现训练循环非常简单。它由一个简单的for循环组成,在每次迭代期间,我们将一批新的真实图像传递给训练步骤。我们还将抽样一些合成图像,并偶尔保存模型权重。

请注意,我们使用tqdm包跟踪进度,该包在训练期间显示完成的百分比:

def train(dataset, epochs, save_every=10):
    for epoch in tqdm(range(epochs)):
        for img_batch in dataset:
            train_step(img_batch)
        # produce images for the GIF as we go
        display.clear_output(wait=True)
        generate_and_save_images(generator, epoch + 1, seed)
        # Save the model every 10 EPOCHS
        if (epoch + 1) % save_every == 0:
            checkpoint.save(file_prefix=checkpoint_prefix)
        # Generator after final epoch
    display.clear_output(wait=True)
    generate_and_save_images(generator, epochs, seed)
train(train_set, EPOCHS) 

评估结果

在仅需几分钟的 100 轮迭代之后,从随机噪声创建的合成图像明显开始类似于原始图像,您可以在图 21.3中看到(请参阅笔记本以获取最佳的视觉质量):

图 21.3:合成 Fashion-MNIST 图像的样本

笔记本还创建了一个动态的 GIF 图像,可视化合成图像的质量在训练期间如何改善。

现在我们了解了如何使用 TensorFlow 2 构建和训练 GAN,我们将转向一个更复杂的示例,该示例从股价数据生成合成时间序列。

用于合成金融数据的 TimeGAN

生成合成时间序列数据面临着特定的挑战,这些挑战超出了设计用于图像的 GAN 时所遇到的挑战。除了在任何给定点上的变量分布外,例如像素值或大量股票的价格,用于时间序列数据的生成模型还应该学习塑造观测序列之间如何跟随的时间动态。(也参见第九章用于波动预测和统计套利的时间序列模型中的讨论)。

Yoon、Jarrett 和 van der Schaar 在 2019 年 12 月的 NeurIPS 上提出的非常新颖且有前景的研究,引入了一种新型的时间序列生成对抗网络TimeGAN)框架,旨在通过结合监督和无监督训练来解释时间相关性。该模型在优化监督和对抗目标的同时学习时间序列嵌入空间,这些目标鼓励模型在训练期间从历史数据中采样时遵循观察到的动态。作者对各种时间序列(包括历史股票价格)进行了模型测试,并发现合成数据的质量明显优于现有替代品。

在本节中,我们将概述这个复杂模型的工作原理,重点介绍建立在以前 DCGAN 示例基础上的关键实现步骤,并展示如何评估生成时间序列的质量。更多信息请参阅论文。

学习跨特征和时间生成数据

用于时间序列数据的成功生成模型需要捕捉每个时间点上特征的横截面分布以及这些特征随时间的纵向关系。用我们刚讨论的图像上下文来表达,模型不仅需要学习一个真实图像是什么样子,还需要学习一个图像如何从前一个图像演变而来,就像视频一样。

结合对抗和监督训练

正如第一节中提到的那样,以前生成时间序列数据的尝试,如 RGAN 和 RCGAN,依赖于 RNN(请参阅第十九章用于多变量时间序列和情感分析的 RNN)充当生成器和判别器的角色。TimeGAN 通过将 DCGAN 示例中的无监督对抗损失应用于真实和合成序列,并与相对于原始数据的逐步监督损失结合,明确地结合了时间序列的自回归特性。其目标是奖励模型学习存在于历史数据中的从一个时间点到下一个时间点的转换分布

此外,TimeGAN 包括一个嵌入网络,将时间序列特征映射到较低维度的潜在空间,以降低对抗空间的复杂性。其动机是捕捉通常具有较低维度的时间动态的驱动因素。(还请参阅第十三章中的流形学习讨论,使用无监督学习的数据驱动风险因素和资产配置,以及第二十章中的非线性降维讨论,用于条件风险因素和资产定价的自编码器)。

TimeGAN 架构的一个关键元素是,生成器和嵌入(或自动编码器)网络都负责最小化监督损失,这个损失度量模型学习动态关系的好坏。因此,模型学习了一个潜在空间,有助于生成器忠实地再现历史数据中观察到的时间关系。除了时间序列数据,模型还可以处理静态数据,即随时间不变或随时间变化较少的数据。

TimeGAN 架构的四个组成部分

TimeGAN 架构将对抗网络与自动编码器结合在一起,因此有四个网络组件,如 图 21.4 所示:

  1. 自动编码器:嵌入和恢复网络

  2. 对抗网络:序列生成器和序列鉴别器组件

作者强调通过三种不同的损失函数来进行自动编码器和对抗网络的联合训练重建损失优化自动编码器,无监督损失训练对抗网络,监督损失强制执行时间动态。由于这一关键见解,TimeGAN 同时学会了编码特征、生成表示和在时间上迭代。更具体地说,嵌入网络创建潜在空间,对抗网络在此空间内运作,监督损失同步了真实数据和合成数据的潜在动态。

图 21.4:TimeGAN 架构的组件

自动编码器的嵌入和恢复组件将特征空间映射到潜在空间,反之亦然。这有助于对抗网络学习低维空间中的时间动态。作者使用堆叠 RNN 和前馈网络实现了嵌入和恢复网络。然而,只要它们是自回归的,并且尊重数据的时间顺序,这些选择可以灵活地适应手头的任务。

对抗网络的生成器和鉴别器元素与 DCGAN 不同,不仅因为它们作用于序列数据,而且因为合成特征是在模型同时学习的潜在空间中生成的。作者选择了 RNN 作为生成器,选择了具有前向输出层的双向 RNN 作为鉴别器。

自动编码器和对抗网络的联合训练

图 21.4 中显示的三个损失函数驱动了刚刚描述的网络元素的联合优化,同时在真实和随机生成的时间序列上训练。更详细地说,它们旨在实现以下目标:

  • 重建损失是我们在第二十章中对自动编码器的讨论中熟悉的;它比较重构的编码数据与原始数据的相似程度。

  • 无监督损失反映了生成器和鉴别器之间的竞争交互,如 DCGAN 示例中所述;生成器旨在最小化鉴别器将其输出分类为伪造的概率,而鉴别器旨在优化对真实和伪造输入的正确分类。

  • 监督损失捕捉生成器在接收编码的真实数据进行前序列时,在潜在空间中逼近实际的下一个时间步骤的程度。

训练分为三个阶段

  1. 在实际时间序列上训练自动编码器以优化重建。

  2. 使用实时时间序列优化监督损失,以捕捉历史数据的时间动态。

  3. 联合训练四个组件,同时最小化所有三个损失函数。

TimeGAN 包括用于加权复合损失函数组件的几个超参数;然而,作者发现网络对这些设置的敏感性要小于人们可能预期的,考虑到 GAN 训练的困难。事实上,他们在训练过程中没有发现重大挑战,并建议嵌入任务用于正则化对抗学习,因为它降低了其维度,而监督损失则约束了生成器的逐步动态。

现在我们转向使用 TensorFlow 2 实现 TimeGAN;请参阅论文以了解该方法的数学和方法的深入解释。

使用 TensorFlow 2 实现 TimeGAN

在本节中,我们将实现刚刚描述的 TimeGAN 架构。作者提供了使用 TensorFlow 1 的示例代码,我们将其移植到 TensorFlow 2。构建和训练 TimeGAN 需要几个步骤:

  1. 选择和准备真实和随机时间序列输入

  2. 创建关键的 TimeGAN 模型组件

  3. 定义在三个训练阶段使用的各种损失函数和训练步骤。

  4. 运行训练循环并记录结果。

  5. 生成合成时间序列并评估结果。

我们将逐步介绍每个步骤的关键项目;请参阅笔记本TimeGAN_TF2以获取本节中的代码示例(除非另有说明),以及其他实现细节。

准备真实和随机输入系列

作者使用了从 Yahoo Finance 下载的 15 年每日谷歌股票价格作为金融数据的 TimeGAN 适用性示例,包括六个特征,即开盘价、最高价、最低价、收盘价和调整后的收盘价以及交易量。我们将使用近 20 年的六个不同股票的调整后的收盘价,因为它引入了较高的变异性。我们将按照原始论文的要求,针对 24 个时间步长生成合成序列。

在 Quandl Wiki 数据集中历史最悠久的股票中,有一些是以归一化格式显示的,即从 1.0 开始,在 图 21.5 中显示。我们从 2000 年至 2017 年检索调整后的收盘价,并获得 4,000 多个观察结果。系列之间的相关系数从 GE 和 CAT 的 0.01 到 DIS 和 KO 的 0.94 不等。

图 21.5:TimeGAN 输入-六个真实股票价格系列

我们使用 scikit-learn 的 MinMaxScaler 类将每个系列缩放到范围 [0, 1],稍后我们将使用它来重新缩放合成数据:

df = pd.read_hdf(hdf_store, 'data/real')
scaler = MinMaxScaler()
scaled_data = scaler.fit_transform(df).astype(np.float32) 

在下一步中,我们创建包含六个系列的 24 个连续数据点的重叠序列的滚动窗口:

data = []
for i in range(len(df) - seq_len):
    data.append(scaled_data[i:i + seq_len])
n_series = len(data) 

然后,我们从 NumPy 数组列表创建一个 tf.data.Dataset 实例,确保数据在训练时被洗牌,并设置批量大小为 128:

real_series = (tf.data.Dataset
               .from_tensor_slices(data)
               .shuffle(buffer_size=n_windows)
               .batch(batch_size))
real_series_iter = iter(real_series.repeat()) 

我们还需要一个随机时间序列生成器,它会生成模拟数据,每个时间序列有 24 个观测值,直到训练结束。

为此,我们将创建一个生成器,它随机均匀地抽取所需数据,并将结果输入到第二个 tf.data.Dataset 实例中。我们将设置此数据集以产生所需大小的批量,并为必要的时间重复该过程:

def make_random_data():
    while True:
        yield np.random.uniform(low=0, high=1, size=(seq_len, n_seq))
random_series = iter(tf.data.Dataset
                     .from_generator(make_random_data,
                                     output_types=tf.float32)
                     .batch(batch_size)
                     .repeat()) 

现在我们将继续定义并实例化 TimeGAN 模型组件。

创建 TimeGAN 模型组件

我们现在将创建两个自编码器组件和两个对抗网络元素,以及鼓励生成器学习历史价格系列的监督员。

我们将按照作者的示例代码创建具有三个隐藏层的 RNN,每个隐藏层有 24 个 GRU 单元,除了监督员,它只使用两个隐藏层。以下的 make_rnn 函数自动创建网络:

def make_rnn(n_layers, hidden_units, output_units, name):
    return Sequential([GRU(units=hidden_units,
                           return_sequences=True,
                           name=f'GRU_{i + 1}') for i in range(n_layers)] +
                      [Dense(units=output_units,
                             activation='sigmoid',
                             name='OUT')], name=name) 

自编码器嵌入器 和我们在这里实例化的恢复网络组成:

embedder = make_rnn(n_layers=3, 
                    hidden_units=hidden_dim, 
                    output_units=hidden_dim, 
                    name='Embedder')
recovery = make_rnn(n_layers=3, 
                    hidden_units=hidden_dim, 
                    output_units=n_seq, 
                    name='Recovery') 

然后我们像这样创建生成器、鉴别器和监督员:

generator = make_rnn(n_layers=3, 
                     hidden_units=hidden_dim, 
                     output_units=hidden_dim, 
                     name='Generator')
discriminator = make_rnn(n_layers=3, 
                         hidden_units=hidden_dim, 
                         output_units=1, 
                         name='Discriminator')
supervisor = make_rnn(n_layers=2, 
                      hidden_units=hidden_dim, 
                      output_units=hidden_dim, 
                      name='Supervisor') 

我们还定义了两个通用损失函数,即 MeanSquaredErrorBinaryCrossEntropy,稍后我们将使用它们来创建三个阶段中的各种特定损失函数:

mse = MeanSquaredError()
bce = BinaryCrossentropy() 

现在是时候开始训练过程了。

第 1 阶段训练 - 使用真实数据的自编码器

自编码器整合了嵌入器和恢复函数,就像我们在上一章中看到的那样:

H = embedder(X)
X_tilde = recovery(H)
autoencoder = Model(inputs=X,
                    outputs=X_tilde,
                    name='Autoencoder')
autoencoder.summary()
Model: "Autoencoder"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
RealData (InputLayer)        [(None, 24, 6)]           0         
_________________________________________________________________
Embedder (Sequential)        (None, 24, 24)            10104     
_________________________________________________________________
Recovery (Sequential)        (None, 24, 6)             10950     
=================================================================
Trainable params: 21,054 

它有 21,054 个参数。我们现在将为这个训练阶段实例化优化器并定义训练步骤。它遵循了与 DCGAN 示例引入的模式,使用 tf.GradientTape 来记录生成重构损失的操作。这允许我们依赖于自动微分引擎来获取相对于驱动 反向传播 的可训练嵌入器和恢复网络权重的梯度:

autoencoder_optimizer = Adam()
@tf.function
def train_autoencoder_init(x):
    with tf.GradientTape() as tape:
        x_tilde = autoencoder(x)
        embedding_loss_t0 = mse(x, x_tilde)
        e_loss_0 = 10 * tf.sqrt(embedding_loss_t0)
    var_list = embedder.trainable_variables + recovery.trainable_variables
    gradients = tape.gradient(e_loss_0, var_list)
    autoencoder_optimizer.apply_gradients(zip(gradients, var_list))
    return tf.sqrt(embedding_loss_t0) 

重建损失简单地将自动编码器的输出与其输入进行比较。我们使用这个训练循环进行 10,000 步训练,只需一分钟多一点时间,并记录步骤损失以在 TensorBoard 上进行监控:

for step in tqdm(range(train_steps)):
    X_ = next(real_series_iter)
    step_e_loss_t0 = train_autoencoder_init(X_)
    with writer.as_default():
        tf.summary.scalar('Loss Autoencoder Init', step_e_loss_t0, step=step) 

第二阶段训练 – 使用真实数据进行监督学习

我们已经创建了监督模型,所以我们只需要实例化优化器并定义训练步骤如下:

supervisor_optimizer = Adam()
@tf.function
def train_supervisor(x):
    with tf.GradientTape() as tape:
        h = embedder(x)
        h_hat_supervised = supervisor(h)
        g_loss_s = mse(h[:, 1:, :], h_hat_supervised[:, 1:, :])
    var_list = supervisor.trainable_variables
    gradients = tape.gradient(g_loss_s, var_list)
    supervisor_optimizer.apply_gradients(zip(gradients, var_list))
    return g_loss_s 

在这种情况下,损失比较监督器的输出与嵌入序列的下一个时间步,以便它学习历史价格序列的时间动态;训练循环的工作方式与前一章中的自动编码器示例类似。

第三阶段训练 – 使用真实数据和随机数据进行联合训练

联合训练涉及所有四个网络组件以及监督器。它使用多个损失函数和基本组件的组合来实现潜在空间嵌入、过渡动态和合成数据生成的同时学习。

我们将突出几个显著的例子;请查看笔记本以获取包含我们将在此省略的一些重复步骤的完整实现。

为了确保生成器能够忠实地复制时间序列,TimeGAN 包含一个时刻损失,当合成数据的均值和方差偏离真实版本时会受到惩罚:

def get_generator_moment_loss(y_true, y_pred):
    y_true_mean, y_true_var = tf.nn.moments(x=y_true, axes=[0])
    y_pred_mean, y_pred_var = tf.nn.moments(x=y_pred, axes=[0])
    g_loss_mean = tf.reduce_mean(tf.abs(y_true_mean - y_pred_mean))
    g_loss_var = tf.reduce_mean(tf.abs(tf.sqrt(y_true_var + 1e-6) - 
                                       tf.sqrt(y_pred_var + 1e-6)))
    return g_loss_mean + g_loss_var 

产生合成数据的端到端模型涉及生成器、监督器和恢复组件。它的定义如下,并且有接近 30,000 个可训练参数:

E_hat = generator(Z)
H_hat = supervisor(E_hat)
X_hat = recovery(H_hat)
synthetic_data = Model(inputs=Z,
                       outputs=X_hat,
                       name='SyntheticData')
Model: "SyntheticData"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
RandomData (InputLayer)      [(None, 24, 6)]           0         
_________________________________________________________________
Generator (Sequential)       (None, 24, 24)            10104     
_________________________________________________________________
Supervisor (Sequential)      (None, 24, 24)            7800      
_________________________________________________________________
Recovery (Sequential)        (None, 24, 6)             10950     
=================================================================
Trainable params: 28,854 

联合训练涉及自动编码器、生成器和鉴别器的三个优化器:

generator_optimizer = Adam()
discriminator_optimizer = Adam()
embedding_optimizer = Adam() 

生成器的训练步骤说明了使用四个损失函数和相应的网络组件组合来实现本节开头所概述的所需学习的用途:

@tf.function
def train_generator(x, z):
    with tf.GradientTape() as tape:
        y_fake = adversarial_supervised(z)
        generator_loss_unsupervised = bce(y_true=tf.ones_like(y_fake),
                                          y_pred=y_fake)
        y_fake_e = adversarial_emb(z)
        generator_loss_unsupervised_e = bce(y_true=tf.ones_like(y_fake_e),
                                            y_pred=y_fake_e)
        h = embedder(x)
        h_hat_supervised = supervisor(h)
        generator_loss_supervised = mse(h[:, 1:, :], 
                                        h_hat_supervised[:, 1:, :])
        x_hat = synthetic_data(z)
        generator_moment_loss = get_generator_moment_loss(x, x_hat)
        generator_loss = (generator_loss_unsupervised +
                          generator_loss_unsupervised_e +
                          100 * tf.sqrt(generator_loss_supervised) +
                          100 * generator_moment_loss)
    var_list = generator.trainable_variables + supervisor.trainable_variables
    gradients = tape.gradient(generator_loss, var_list)
    generator_optimizer.apply_gradients(zip(gradients, var_list))
    return (generator_loss_unsupervised, generator_loss_supervised,
            generator_moment_loss) 

最后,联合训练循环将各种训练步骤汇集起来,并建立在阶段 1 和 2 的学习基础上,以在真实数据和随机数据上训练 TimeGAN 组件。我们在不到 40 分钟内运行这个循环 10,000 次迭代:

for step in range(train_steps):
    # Train generator (twice as often as discriminator)
    for kk in range(2):
        X_ = next(real_series_iter)
        Z_ = next(random_series)
        # Train generator
        step_g_loss_u, step_g_loss_s, step_g_loss_v = train_generator(X_, Z_)
        # Train embedder
        step_e_loss_t0 = train_embedder(X_)
    X_ = next(real_series_iter)
    Z_ = next(random_series)
    step_d_loss = get_discriminator_loss(X_, Z_)
    if step_d_loss > 0.15:
        step_d_loss = train_discriminator(X_, Z_)
    if step % 1000 == 0:
        print(f'{step:6,.0f} | d_loss: {step_d_loss:6.4f} | '
              f'g_loss_u: {step_g_loss_u:6.4f} | '
              f'g_loss_s: {step_g_loss_s:6.4f} | '
              f'g_loss_v: {step_g_loss_v:6.4f} | '
              f'e_loss_t0: {step_e_loss_t0:6.4f}')
    with writer.as_default():
        tf.summary.scalar('G Loss S', step_g_loss_s, step=step)
        tf.summary.scalar('G Loss U', step_g_loss_u, step=step)
        tf.summary.scalar('G Loss V', step_g_loss_v, step=step)
        tf.summary.scalar('E Loss T0', step_e_loss_t0, step=step)
        tf.summary.scalar('D Loss', step_d_loss, step=step) 

现在我们终于可以生成合成的时间序列了!

生成合成的时间序列

为了评估 TimeGAN 的结果,我们将通过绘制随机输入并将其馈送到前面描述的 synthetic_data 网络来生成合成时间序列。更具体地说,我们将创建大致与真实数据集中重叠窗口数量相同的 24 个观察值的人工系列,涵盖六个股票:

generated_data = []
for i in range(int(n_windows / batch_size)):
    Z_ = next(random_series)
    d = synthetic_data(Z_)
    generated_data.append(d)
len(generated_data)
35 

结果是包含 128 个样本的 35 批次,每个样本的维度为 24×6,我们按以下方式堆叠:

generated_data = np.array(np.vstack(generated_data))
generated_data.shape
(4480, 24, 6) 

我们可以使用训练好的 MinMaxScaler 将合成输出恢复到输入序列的比例:

generated_data = (scaler.inverse_transform(generated_data
                                           .reshape(-1, n_seq))
                  .reshape(-1, seq_len, n_seq)) 

图 21.6 显示了六个合成系列和相应的真实系列的样本。合成数据通常反映了与其真实对应物不太相似的行为变化,并且经过重新缩放后,大致(由于随机输入)匹配其范围:

图 21.6:TimeGAN 输出——六个合成价格序列及其真实对应物

现在是时候更深入地评估合成数据的质量了。

评估合成时间序列数据的质量

TimeGAN 的作者根据三个实用标准评估生成数据的质量:

  • 多样性:合成样本的分布应大致与真实数据相匹配。

  • 忠实度:样本序列应与真实数据无法区分。

  • 有用性:合成数据应与其真实对应物一样有用于解决预测任务。

他们应用了三种方法来评估合成数据是否实际具有这些特征:

  • 可视化:为了定性多样性评估多样性,我们使用降维技术——主成分分析PCA)和 t-SNE(见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)——来直观地检查合成样本的分布与原始数据的相似程度。

  • 区分分数:作为忠实度的定量评估,时间序列分类器的测试错误(例如两层 LSTM,见第十八章金融时间序列和卫星图像的 CNNs)让我们评估真实和合成时间序列是否可以区分,或者实际上是无法区分的。

  • 预测分数:作为有用性的定量衡量,我们可以比较在训练了基于实际数据或合成数据的序列预测模型后,预测下一个时间步骤的测试错误。

我们将在接下来的章节中应用并讨论每种方法的结果。有关代码示例和额外细节,请参阅笔记本 evaluating_synthetic_data

评估多样性——使用 PCA 和 t-SNE 进行可视化。

为了可视化具有 24 个时间步长和六个特征的真实和合成序列,我们将降低它们的维度,以便可以将它们绘制在二维平面上。为此,我们将抽样 250 个归一化的具有六个特征的序列,然后将它们重塑为维度为 1,500×24 的数据(仅展示真实数据的步骤;有关合成数据,请参阅笔记本):

# same steps to create real sequences for training
real_data = get_real_data()
# reload synthetic data
synthetic_data = np.load('generated_data.npy')
synthetic_data.shape
(4480, 24, 6)
# ensure same number of sequences
real_data = real_data[:synthetic_data.shape[0]]
sample_size = 250
idx = np.random.permutation(len(real_data))[:sample_size]
real_sample = np.asarray(real_data)[idx]
real_sample_2d = real_sample.reshape(-1, seq_len)
real_sample_2d.shape
(1500, 24) 

PCA 是一种线性方法,它确定一个新的基底,其中相互正交的向量依次捕获数据中的最大方差方向。我们将使用真实数据计算前两个分量,然后将真实和合成样本都投影到新的坐标系上:

pca = PCA(n_components=2)
pca.fit(real_sample_2d)
pca_real = (pd.DataFrame(pca.transform(real_sample_2d))
            .assign(Data='Real'))
pca_synthetic = (pd.DataFrame(pca.transform(synthetic_sample_2d))
                 .assign(Data='Synthetic')) 

t-SNE 是一种非线性流形学习方法,用于可视化高维数据。它将数据点之间的相似性转换为联合概率,并旨在最小化低维嵌入和高维数据之间的 Kullback-Leibler 散度(参见第十三章使用无监督学习进行数据驱动的风险因子和资产配置)。我们计算组合的真实和合成数据的 t-SNE 如下所示:

tsne_data = np.concatenate((real_sample_2d,  
                            synthetic_sample_2d), axis=0)
tsne = TSNE(n_components=2, perplexity=40)
tsne_result = tsne.fit_transform(tsne_data) 

图 21.7 显示了用于定性评估真实和合成数据分布相似性的 PCA 和 t-SNE 结果。两种方法都显示了明显相似的模式和显著重叠,表明合成数据捕获了真实数据特征的重要方面。

图 21.7: 两个维度中真实和合成数据的 250 个样本

评估保真度 – 时间序列分类性能

可视化仅提供了定性印象。为了定量评估合成数据的保真度,我们将训练一个时间序列分类器来区分真实数据和伪造数据,并评估其在保留的测试集上的性能。

更具体地说,我们将选择滚动序列的前 80% 进行训练,将最后 20% 作为测试集,如下所示:

synthetic_data.shape
(4480, 24, 6)
n_series = synthetic_data.shape[0]
idx = np.arange(n_series)
n_train = int(.8*n_series)
train_idx, test_idx = idx[:n_train], idx[n_train:]
train_data = np.vstack((real_data[train_idx], 
                        synthetic_data[train_idx]))
test_data = np.vstack((real_data[test_idx], 
                       synthetic_data[test_idx]))
n_train, n_test = len(train_idx), len(test_idx)
train_labels = np.concatenate((np.ones(n_train),
                               np.zeros(n_train)))
test_labels = np.concatenate((np.ones(n_test),
                              np.zeros(n_test))) 

然后,我们将创建一个简单的 RNN,它有六个单元,接收形状为 24×6 的真实和合成系列的小批量,并使用 sigmoid 激活。我们将使用二元交叉熵损失和 Adam 优化器进行优化,同时跟踪 AUC 和准确度指标:

ts_classifier = Sequential([GRU(6, input_shape=(24, 6), name='GRU'),
                            Dense(1, activation='sigmoid', name='OUT')])
ts_classifier.compile(loss='binary_crossentropy',
                      optimizer='adam',
                      metrics=[AUC(name='AUC'), 'accuracy'])
Model: "Time Series Classifier"
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
GRU (GRU)                    (None, 6)                 252       
_________________________________________________________________
OUT (Dense)                  (None, 1)                 7         
=================================================================
Total params: 259
Trainable params: 259 

模型有 259 个可训练参数。我们将在 128 个随机选择的样本的批次上进行 250 个时期的训练,并跟踪验证性能:

result = ts_classifier.fit(x=train_data,
                           y=train_labels,
                           validation_data=(test_data, test_labels),
                           epochs=250, batch_size=128) 

训练完成后,对测试集的评估结果表明,平衡测试集的分类错误率接近 56%,AUC 非常低,仅为 0.15:

ts_classifier.evaluate(x=test_data, y=test_labels)
56/56 [==============================] - 0s 2ms/step - loss: 3.7510 - AUC: 0.1596 - accuracy: 0.4403 

图 21.8 绘制了训练和测试数据的准确度和 AUC 性能指标在 250 个训练时期上的情况:

图 21.8: 250 个时期的时间序列分类器的训练和测试性能

图表显示,该模型无法学习区分真实数据和合成数据的差异,并将其推广到测试集。这一结果表明,合成数据的质量符合保真标准。

评估实用性 – 在合成数据上训练,在真实数据上测试

最后,我们想知道在解决预测问题时合成数据的实用性如何。为此,我们将交替在合成数据和真实数据上训练一个时间序列预测模型,以预测下一个时间步,并比较在由真实数据创建的测试集上的性能。

具体来说,我们将选择每个序列的前 23 个时间步作为输入,最后一个时间步作为输出。与前面的分类示例相同,我们将使用相同的时间拆分将真实数据分为训练集和测试集:

real_data.shape, synthetic_data.shape
((4480, 24, 6), (4480, 24, 6))
real_train_data = real_data[train_idx, :23, :]
real_train_label = real_data[train_idx, -1, :]
real_test_data = real_data[test_idx, :23, :]
real_test_label = real_data[test_idx, -1, :]
real_train_data.shape, real_train_label.shape
((3584, 23, 6), (3584, 6)) 

我们将选择完整的合成数据进行训练,因为丰富性是我们首次生成它的原因之一:

synthetic_train = synthetic_data[:, :23, :]
synthetic_label = synthetic_data[:, -1, :]
synthetic_train.shape, synthetic_label.shape
((4480, 23, 6), (4480, 6)) 

我们将创建一个具有 12 个 GRU 单元的一层 RNN,用于预测六个股价系列的最后时间步,并因此具有六个线性输出单元。该模型使用 Adam 优化器来最小化平均绝对误差(MAE):

def get_model():
    model = Sequential([GRU(12, input_shape=(seq_len-1, n_seq)),
                        Dense(6)])
    model.compile(optimizer=Adam(), 
                  loss=MeanAbsoluteError(name='MAE'))
    return model 

我们将分别使用合成和真实数据对模型进行两次训练,使用真实的测试集来评估样本外表现。合成数据的训练工作如下;真实数据的训练工作类似(请参见笔记本):

ts_regression = get_model()
synthetic_result = ts_regression.fit(x=synthetic_train,
                                     y=synthetic_label,
                                     validation_data=(
                                         real_test_data, 
                                         real_test_label),
                                     epochs=100,
                                     batch_size=128) 

图 21.9 绘制了两种模型在训练集和测试集上的 MAE(以对数刻度绘制,以便我们可以发现差异)。结果表明,在合成数据集上训练后,MAE 稍微更低:

图 21.9:时间序列预测模型在 100 个时期内的训练和测试性能

结果表明,合成训练数据确实可能是有用的。在预测六个股票的下一个日收盘价的特定预测任务中,一个简单的模型在合成 TimeGAN 数据上的训练效果与在真实数据上的训练效果相同或更好。

得到的经验教训和下一步计划

我们在整本书中都遇到了过度拟合的永恒问题,这意味着生成有用的合成数据的能力可能会非常有价值。TimeGAN 的例子在这方面证明了谨慎的乐观态度。与此同时,还有一些警告:我们为少数资产以日频率生成了价格数据。实际上,我们可能对更多资产的回报感兴趣,可能是以更高的频率。横截面和时间动态肯定会变得更加复杂,并且可能需要对 TimeGAN 的架构和训练过程进行调整。

然而,实验的这些限制,虽然有希望,但意味着自然的下一步:我们需要将范围扩展到包含除价格以外的其他信息的更高维时间序列,并且还需要在更复杂的模型环境中测试它们的有用性,包括特征工程。合成训练数据的这些都是非常早期的阶段,但这个例子应该能让您追求自己的研究议程,朝着更加现实的解决方案前进。

摘要

在本章中,我们介绍了 GAN,它们学习输入数据上的概率分布,因此能够生成代表目标数据的合成样本。

虽然这个非常新的创新有许多实际应用,但如果在医学领域生成时间序列训练数据的成功能够转移到金融市场数据上,那么它们可能对算法交易特别有价值。我们学习了如何使用 TensorFlow 设置对抗性训练。我们还探讨了 TimeGAN,这是一个最近的例子,专门用于生成合成时间序列数据。

在下一章中,我们将重点关注强化学习,在这里我们将构建与它们(市场)环境交互学习的代理。

第二十二章:深度强化学习 - 构建交易代理

在本章中,我们将介绍强化学习RL),它与我们迄今为止涵盖的监督和无监督算法的机器学习ML)方法不同。RL 吸引了极大的关注,因为它是一些最令人兴奋的人工智能突破的主要推动力,比如 AlphaGo。AlphaGo 的创造者、Google 拥有的 DeepMind 的首席 RL 研究员 David Silver 最近荣获了 2019 年的重要 ACM 计算奖,以表彰其在计算机游戏中取得的突破性进展。我们将看到,RL 的交互式和在线特性使其特别适合于交易和投资领域。

RL 模型通过与通常具有不完全信息的随机环境交互的代理进行目标导向学习。RL 的目标是通过从奖励信号中学习状态和动作的价值来自动化代理如何做出决策以实现长期目标。最终目标是推导出一个策略,该策略编码了行为规则并将状态映射到动作。

RL 被认为是最接近人类学习的方法,它是通过在现实世界中采取行动并观察后果而产生的。它与监督学习不同,因为它根据一个标量奖励信号一次优化代理的行为体验,而不是通过从正确标记的、代表性的目标概念样本中泛化。此外,RL 不仅仅停留在做出预测。相反,它采用了端到端的目标导向决策视角,包括动作及其后果。

在本章中,您将学习如何制定 RL 问题并应用各种解决方法。我们将涵盖基于模型和无模型方法,介绍 OpenAI Gym 环境,并将深度学习与 RL 结合起来,训练一个在复杂环境中导航的代理。最后,我们将向您展示如何通过建模与金融市场互动的代理来调整 RL,以优化其利润目标。

更具体地说,在阅读本章后,您将能够:

  • 定义马尔可夫决策问题MDP

  • 使用值迭代和策略迭代来解决 MDP

  • 在具有离散状态和动作的环境中应用 Q 学习

  • 在连续环境中构建和训练一个深度 Q 学习代理

  • 使用 OpenAI Gym 训练 RL 交易代理

您可以在 GitHub 仓库的相应目录中找到本章的代码示例和额外资源的链接。笔记本包括图像的彩色版本。

强化学习系统的要素

强化学习问题具有几个元素,使它们与我们迄今为止涵盖的机器学习设置有所不同。以下两个部分概述了定义和解决强化学习问题所需的关键特征,通过学习一个自动化决策的策略。我们将使用符号和通常遵循强化学习:导论(Sutton 和 Barto 2018)以及 David Silver 的 UCL 强化学习课程 (www.davidsilver.uk/teaching/),这些都是进一步研究的推荐材料,超出了本章范围的简要概述。

强化学习问题旨在解决优化代理目标的行动,考虑到对环境的一些观察。环境向代理提供其状态的信息,为动作分配奖励,并使代理转移到新状态,受到代理可能知道的概率分布的约束。它可能是完全或部分可观察的,还可能包含其他代理。环境的结构对代理学习给定任务的能力有着很大的影响,通常需要大量的前期设计工作来促进训练过程。

强化学习问题基于环境状态和代理动作空间的复杂性而有所不同,可以是离散的或连续的。连续的动作和状态,除非离散化,否则需要机器学习来近似状态、动作和其价值之间的函数关系。它们还需要泛化,因为代理在训练期间几乎肯定只经历了潜在无限数量的状态和动作的子集。

解决复杂的决策问题通常需要一个简化的模型,将关键方面隔离出来。图 22.1突显了强化学习问题的显著特征。这些特征通常包括:

  • 代理对环境状态的观察

  • 一组可供代理选择的动作

  • 管理代理决策的策略

图 22.1

图 22.1:强化学习系统的组成部分

此外,环境会发出奖励信号(可能是负数),因为代理的动作导致了状态转换到一个新状态。在其核心,代理通常学习一个价值函数,它指导其对可用动作的判断。代理的目标函数处理奖励信号,并将价值判断转化为最优策略。

策略 - 将状态转换为动作

在任何时刻,策略定义了代理的行为。它将代理可能遇到的任何状态映射到一个或多个动作。在具有有限状态和动作数量的环境中,策略可以是一个简单的查找表,在训练期间填充。

借助连续状态和动作,策略采用机器学习可以帮助近似的函数形式。策略也可能涉及大量计算,例如 AlphaZero,它使用树搜索来决定给定游戏状态的最佳动作。策略也可能是随机的,并给定一个状态,为动作分配概率。

奖励——从动作中学习

奖励信号是环境在每个时间步发送给代理程序的单个值。代理的目标通常是最大化随时间接收到的总奖励。奖励也可以是状态和动作的随机函数。它们通常被折扣以促进收敛并反映价值的时间衰减。

奖励是代理程序了解其在给定状态下决策价值的唯一途径,并相应调整策略。由于奖励信号对代理程序学习的重要影响,因此奖励信号通常是设计强化学习系统中最具挑战性的部分。

奖励需要清楚地传达代理应该完成的任务(而不是如何完成),可能需要领域知识来正确编码这些信息。例如,交易代理的开发可能需要为买入、持有和卖出决策定义奖励。这些可能仅限于利润和损失,但也可能需要包括波动率和风险考虑,例如回撤。

价值函数——长期选择的最优解

奖励提供了对动作的即时反馈。然而,解决强化学习问题需要能够从长远角度创造价值的决策。这就是价值函数的作用所在:它总结了状态或给定状态下动作的效用,以长期奖励的形式。

换句话说,状态的价值是代理程序在未来在该状态下可以期望获得的总奖励。即时奖励可能是未来奖励的良好代理,但代理还需要考虑到低奖励后面很可能出现的更好结果(或者相反)。

因此,值估计旨在预测未来的奖励。奖励是关键输入,而进行值估计的目标是获得更多的奖励。然而,强化学习方法专注于学习准确的值,以便在有效利用(通常有限的)经验的同时做出良好的决策。

还有一些强化学习方法不依赖于值函数,例如基因算法或模拟退火等随机优化方法,这些方法旨在通过有效地探索策略空间来找到最佳行为。然而,目前对强化学习的兴趣主要受到直接或间接估计状态和动作价值的方法驱动。

策略梯度方法是一种依赖于参数化、可微分策略的新发展,可以直接使用梯度下降优化目标(Sutton 等人,2000)。请参阅 GitHub 上的资源,其中包括超出本章范围的关键论文和算法的摘要。

有模型还是无模型 - 三思而后行?

基于模型的强化学习方法学习环境的模型,以使代理能够通过预测其行动的后果来提前规划。例如,这样的模型可以用于根据当前状态和行动预测下一个状态和奖励。这是规划的基础,即通过考虑未来可能发生的情况来决定最佳行动方案。

相反,更简单的无模型方法是通过试错学习的。现代强化学习方法涵盖了从低级试错方法到高级、深思熟虑的规划的整个范围。正确的方法取决于环境的复杂性和可学习性。

如何解决强化学习问题

强化学习方法旨在通过经验学习如何采取能够实现长期目标的行动。为此,代理与环境通过一系列离散的时间步骤进行交互,通过先前部分中描述的行动、状态观测和奖励的接口进行交互。

解决强化学习问题的关键挑战

解决强化学习问题需要解决两个独特的挑战:信用分配问题和探索-利用权衡。

信用分配

在强化学习中,奖励信号可能比导致结果的行动晚得多,使得将行动与其后果联系起来变得复杂。例如,当代理人多次采取 100 个不同的持仓并进行交易时,如果它只了解到投资组合收益,它如何意识到某些持仓表现比其他持仓好得多呢?

信用分配问题是在考虑到这些延迟的情况下准确估计给定状态下行动的利益和成本的挑战。强化学习算法需要找到一种方法,将积极和消极结果的信用分配给可能参与产生这些结果的许多决策。

探索与利用

强化学习的动态和交互性意味着代理需要在经历所有相关轨迹之前估计状态和行动的价值。虽然它可以在任何阶段选择一个动作,但这些决定是基于不完全的学习,但会为代理生成其行为的最佳选择的第一手见解。

对行动价值的部分可见性会导致只利用过去(成功的)经验而不是探索未知领域的决策的风险。这些选择限制了代理的暴露,并阻止它学习最优策略。

RL 算法需要平衡这种探索-利用的权衡——太少的探索可能会产生偏见的值估计和次优策略,而太少的利用则会阻止学习的发生。

解决强化学习问题的基本方法

解决 RL 问题有许多方法,所有这些方法都涉及找到代理的最优行为规则:

  • DP 方法做出了完全了解环境的常常不切实际的假设,但它们是大多数其他方法的概念基础。

  • 蒙特卡洛(MC)方法通过对整个状态-动作-奖励序列进行采样来学习环境以及不同决策的成本和收益。

  • TD 学习通过从更短的序列中学习显著提高了样本效率。为此,它依赖于引导,即根据其自身的先前估计来优化其估计。

当一个 RL 问题包括明确定义的转移概率以及有限数量的状态和动作时,它可以被构建为一个有限 MDP,对于这个 MDP,DP 可以计算出一个精确的解。当前 RL 理论的大部分关注点都集中在有限 MDP 上,但实际应用需要更一般的设置。未知的转移概率需要高效的采样来学习它们的分布。

对于连续状态和/或动作空间的方法通常利用机器学习来近似值函数或策略函数。它们集成了监督学习,特别是前四章讨论的深度学习方法。然而,在 RL 环境中,这些方法面临着明显的挑战:

  • 奖励信号不直接反映目标概念,就像标记的训练样本一样。

  • 观察的分布取决于代理的动作和策略,策略本身是学习过程的主题。

以下各节将介绍和演示各种解决方法。我们将从值迭代和策略迭代的 DP 方法开始,这些方法仅限于已知转移概率的有限 MDP。正如我们将在接下来的部分看到的那样,它们是 Q-learning 的基础,Q-learning 基于 TD 学习,并且不需要关于转移概率的信息。它的目标与 DP 类似,但计算量较少,而且不需要假设环境的完美模型。最后,我们将扩展范围到连续状态,并介绍深度 Q-learning。

解决动态规划问题

有限 MDP 是一个简单但基本的框架。我们将介绍代理人试图优化的奖励轨迹,定义用于制定优化问题的策略和值函数,以及构成解决方法基础的贝尔曼方程。

有限马尔可夫决策问题

MDPs 将代理-环境交互框架化为在构成情节的一系列时间步 t =1,…,T 上的顺序决策问题。时间步骤被假定为离散的,但该框架可以扩展到连续时间。

MDP 提供的抽象使其应用在许多背景下都能轻松适应。时间步骤可以在任意间隔,动作和状态可以采用可以数值化表示的任何形式。

马尔可夫性质意味着当前状态完全描述了过程,即过程没有记忆。当尝试预测过程的未来时,来自过去状态的信息不添加价值。由于这些属性,该框架已用于建模资产价格,这些资产受到在第五章中讨论的有效市场假设的影响,即投资组合优化与绩效评估

状态、动作和奖励序列

MDPs 的运行方式如下:在每一步 t,代理观察环境的状态 并选择一个动作 ,其中 SA 分别是状态和动作的集合。在下一个时间步 t+1,代理接收到一个奖励 并转移到状态 S[t][+1]。随着时间的推移,MDP 产生了一条轨迹 S[0],A[0],R[1],S[1],A[1],R[1],……,直到代理达到终止状态并结束该情节。

有限的 MDP 具有有限数量的动作 A,状态 S 和奖励 R,包括对这些元素的明确定义的离散概率分布。由于马尔可夫性质,这些分布仅依赖于先前的状态和动作。

轨迹的概率性意味着代理最大化未来奖励的期望总和。此外,奖励通常使用因子 进行折现,以反映其时间价值。对于不是周期性的任务,而是无限期进行的任务,需要使用严格小于 1 的折现因子,以避免无限奖励并确保收敛。因此,代理最大化折现的未来回报总和 R[t],表示为 G[t]:

这种关系也可以递归地定义,因为从第二步开始的求和与G[t][+1]折现一次是相同的:

我们将在后面看到,这种递归关系经常用于制定强化学习算法。

值函数——如何估计长期回报

如前所述,一个策略 将所有状态映射到动作的概率分布,以便在状态 S[t] 中选择动作 A[t] 的概率可以表示为 。值函数估计每个状态或状态-动作对的长期回报。找到将状态映射到动作的最佳策略是至关重要的。

对于策略,状态值函数给出了特定状态s的长期价值v,作为代理从s开始然后始终遵循策略的预期回报G。它的定义如下,其中是指当代理遵循策略时的预期值:

同样,我们可以计算状态动作值函数 q(s,a),作为在状态s开始,采取行动,然后始终遵循策略的预期回报:

贝尔曼方程

贝尔曼方程定义了所有状态sS中的值函数与任何其后继状态*s′*之间的递归关系,其遵循策略。它们通过将值函数分解为即时奖励和下一状态的折现值来实现这一点:

这个方程表示,对于给定策略,一个状态的值必须等于其在该策略下的后继状态的预期值,加上到达该后继状态时所获得的预期奖励。

这意味着,如果我们知道当前可用操作的后继状态的值,我们可以向前看一步,计算当前状态的预期值。由于它对所有状态S都成立,该表达式定义了一组方程。对于,也存在类似的关系。

图 22.2总结了这种递归关系:在当前状态下,代理根据策略选择一个动作a。环境通过分配一个取决于结果新状态*s′*的奖励来做出响应:

图 22.2:贝尔曼方程表达的递归关系

从值函数到最优策略

强化学习问题的解是一个优化累积奖励的策略。策略和值函数紧密相关:一个最优策略为每个状态或状态-动作对提供的值估计至少与任何其他策略的值相同,因为该值是给定策略下的累积奖励。因此,最优值函数隐式定义了最优策略并解决了 MDP。

最优值函数 也满足前一节中的贝尔曼方程。这些贝尔曼最优方程可以省略对策略的显式引用,因为它被 隐含。对于 ,递归关系将当前值等同于选择当前状态中最佳动作的即时奖励之和,以及后继状态的期望折现值:

对于最佳状态-动作值函数 ,贝尔曼最优方程将当前状态-动作值分解为隐含当前动作的奖励与所有后继状态中最佳动作的期望值的折现期望值之和:

最优性条件暗示了最佳策略是始终选择最大化贪婪方式中的期望值的动作,即仅考虑单个时间步骤的结果。

由前两个表达式定义的最优性条件由于 max 操作符是非线性的,缺乏封闭形式的解。相反,MDP 解决方案依赖于迭代解法 - 如策略和值迭代或 Q-learning,我们将在下一节中介绍。

策略迭代

DP 是一种解决可以分解为具有递归结构并允许重复使用中间结果的较小、重叠子问题的一般方法。由于递归贝尔曼最优方程和值函数的累积特性,MDP 符合这一要求。更具体地说,最优性原理适用于最优策略,因为最优策略包括选择最优动作然后遵循最优策略。

DP 需要了解 MDP 的转移概率。通常情况下并非如此,但许多更一般情况下的方法都采用类似于 DP 的方法,并从数据中学习缺失的信息。

DP 对于估计值函数的预测任务和专注于最佳决策并输出策略的控制任务非常有用(在此过程中也估计值函数)。

找到最优策略的策略迭代算法重复以下两个步骤,直到策略收敛,即不再发生变化超过给定阈值:

  1. 策略评估:根据当前策略更新值函数。

  2. 策略改进:更新策略,使动作最大化期望的一步值。

策略评估依赖于贝尔曼方程来估计值函数。更具体地说,它选择由当前策略确定的动作,并将导致的奖励以及下一个状态的折现值相加,以更新当前状态的值。

策略改进又改变了策略,使得对于每个状态,策略产生下一状态中产生最高价值的动作。此改进称为贪婪,因为它仅考虑了单个时间步的回报。策略迭代总是收敛到最优策略,并且通常在相对较少的迭代中实现。

值迭代

策略迭代需要在每次迭代后评估所有状态的策略。对于基于搜索树的策略,例如,评估可能是昂贵的,正如前面讨论的那样。

值迭代通过简化此过程来实现,将策略评估和改进步骤折叠在一起。在每个时间步长,它遍历所有状态并基于下一个状态的当前值估计选择最佳的贪婪动作。然后,它使用贝尔曼最优方程所暗示的一步展望来更新当前状态的值函数。

价值函数的相应更新规则 几乎与策略评估更新相同;它只是在可用动作上增加了最大化:

当价值函数收敛并输出从其值函数估计得出的贪婪策略时,算法停止。它也保证收敛到最优策略。

泛化策略迭代

在实践中,有几种方法可以截断策略迭代;例如,在改进之前评估策略k次。这意味着max操作符将仅在每第k次迭代时应用。

大多数强化学习算法估计值和策略函数,并依赖于策略评估和改进的交互来收敛到解决方案,如图 22.3所示。通常方法是相对于值函数改进策略,同时调整值函数使其匹配策略:

图 22.3:策略评估和改进的收敛

收敛要求值函数与策略一致,而策略又需要在相对于值函数的贪婪行为中稳定。因此,只有当找到一个相对于其自身评估函数是贪婪的策略时,这两个过程才会稳定。这意味着贝尔曼最优方程成立,因此策略和值函数是最优的。

Python 中的动态规划

在本节中,我们将把值迭代和策略迭代应用到一个玩具环境中,该环境由一个网格组成,如图 22.4所示,具有以下特征:

  • 状态:11 个状态表示为二维坐标。一个字段不可访问,最右列的顶部两个状态是终止状态,即它们结束了该回合。

  • 动作:向上、向下、向左或向右移动一步。环境是随机的,因此动作可能会产生意外的结果。对于每个动作,有 80%的概率移动到预期状态,并且有 10%的概率移动到相邻方向(例如,向右或向左而不是向上,或者向上/向下而不是向右)。

  • 奖励:如左图所示,除了终止状态的+1/-1 奖励外,每个状态都会产生-.02 的奖励。

图 22.4:3×4 网格世界奖励,值函数和最优策略

设置网格世界

我们将开始定义环境参数:

grid_size = (3, 4)
blocked_cell = (1, 1)
baseline_reward = -0.02
absorbing_cells = {(0, 3): 1, (1, 3): -1}
actions = ['L', 'U', 'R', 'D']
num_actions = len(actions)
probs = [.1, .8, .1, 0] 

我们经常需要在 1D 和 2D 表示之间进行转换,因此我们将为此定义两个辅助函数;状态是一维的,而单元格是相应的 2D 坐标:

to_1d = lambda x: np.ravel_multi_index(x, grid_size)
to_2d = lambda x: np.unravel_index(x, grid_size) 

此外,我们将预先计算一些数据点以使代码更简洁:

num_states = np.product(grid_size)
cells = list(np.ndindex(grid_size))
states = list(range(len(cells)))
cell_state = dict(zip(cells, states))
state_cell= dict(zip(states, cells))
absorbing_states = {to_1d(s):r for s, r in absorbing_cells.items()}
blocked_state = to_1d(blocked_cell) 

我们存储每个状态的奖励:

state_rewards = np.full(num_states, baseline_reward)
state_rewards[blocked_state] = 0
for state, reward in absorbing_states.items():
    state_rewards[state] = reward
state_rewards
array([-0.02, -0.02, -0.02,  1\.  , -0.02,  0\.  , -0.02, -1\.  , -0.02,
       -0.02, -0.02, -0.02]) 

为了考虑到概率环境,我们还需要计算给定动作的实际移动的概率分布:

action_outcomes = {}
for i, action in enumerate(actions):
    probs_ = dict(zip([actions[j % 4] for j in range(i, 
                                               num_actions + i)], probs))
    action_outcomes[actions[(i + 1) % 4]] = probs_
Action_outcomes
{'U': {'L': 0.1, 'U': 0.8, 'R': 0.1, 'D': 0},
 'R': {'U': 0.1, 'R': 0.8, 'D': 0.1, 'L': 0},
 'D': {'R': 0.1, 'D': 0.8, 'L': 0.1, 'U': 0},
 'L': {'D': 0.1, 'L': 0.8, 'U': 0.1, 'R': 0}} 

现在,我们准备计算转移矩阵,这是 MDP 的关键输入。

计算转移矩阵

转移矩阵 定义了对于每个先前状态和动作 A,以及每个状态 S 的结束概率!。我们将演示pymdptoolbox并使用其中一种可用于指定转移和奖励的格式。对于转移概率,我们将创建一个具有维度的 NumPy 数组。

我们首先计算每个起始单元格和移动的目标单元格:

def get_new_cell(state, move):
    cell = to_2d(state)
    if actions[move] == 'U':
        return cell[0] - 1, cell[1]
    elif actions[move] == 'D':
        return cell[0] + 1, cell[1]
    elif actions[move] == 'R':
        return cell[0], cell[1] + 1
    elif actions[move] == 'L':
        return cell[0], cell[1] - 1 

以下函数使用开始stateactionoutcome参数来填充转移概率和奖励:

def update_transitions_and_rewards(state, action, outcome):
    if state in absorbing_states.keys() or state == blocked_state:
        transitions[action, state, state] = 1
    else:
        new_cell = get_new_cell(state, outcome)
        p = action_outcomes[actions[action]][actions[outcome]]
        if new_cell not in cells or new_cell == blocked_cell:
            transitions[action, state, state] += p
            rewards[action, state, state] = baseline_reward
        else:
            new_state= to_1d(new_cell)
            transitions[action, state, new_state] = p
            rewards[action, state, new_state] = state_rewards[new_state] 

我们通过创建占位数据结构并迭代的笛卡尔积来生成转移和奖励值,如下所示:

rewards = np.zeros(shape=(num_actions, num_states, num_states))
transitions = np.zeros((num_actions, num_states, num_states))
actions_ = list(range(num_actions))
for action, outcome, state in product(actions_, actions_, states):
    update_transitions_and_rewards(state, action, outcome)
rewards.shape, transitions.shape
((4,12,12), (4,12,12)) 

实现值迭代算法

我们首先创建值迭代算法,稍微简单一些,因为它在单个步骤中实现策略评估和改进。我们捕获需要更新值函数的状态,排除了值为 0 的终止状态(由于缺乏奖励,+1/-1 分配给起始状态),并跳过阻塞的单元格:

skip_states = list(absorbing_states.keys())+[blocked_state]
states_to_update = [s for s in states if s not in skip_states] 

然后,我们初始化值函数,并设置折扣因子 gamma 和收敛阈值epsilon

V = np.random.rand(num_states)
V[skip_states] = 0
gamma = .99
epsilon = 1e-5 

算法使用贝尔曼最优方程更新值函数,如前所述,并在V的 L1 范数绝对值小于 epsilon 时终止:

while True:
    V_ = np.copy(V)
    for state in states_to_update:
        q_sa = np.sum(transitions[:, state] * (rewards[:, state] + gamma* V), 
                      axis=1)
        V[state] = np.max(q_sa)
    if np.sum(np.fabs(V - V_)) < epsilon:
        break 

该算法在 16 次迭代和 0.0117 秒内收敛。它产生以下最优值估计,连同隐含的最优策略,如本节之前的图 22.4右图所示:

pd.DataFrame(V.reshape(grid_size))
         0         1         2         3
0.884143  0.925054  0.961986  0.000000
1  0.848181  0.000000  0.714643  0.000000
2  0.808344  0.773327  0.736099  0.516082 

定义并运行策略迭代

政策迭代包括单独的评估和改进步骤。我们通过选择最大化预期奖励和下一个状态值的和的动作来定义改进部分。请注意,我们临时填充终端状态的奖励以避免忽略会导致我们到达那里的动作:

def policy_improvement(value, transitions):
    for state, reward in absorbing_states.items():
        value[state] = reward
    return np.argmax(np.sum(transitions * value, 2),0) 

我们像以前一样初始化值函数,并且还包括一个随机起始策略:

pi = np.random.choice(list(range(num_actions)), size=num_states) 

该算法在贪婪选择的动作的政策评估和政策改进之间交替,直到策略稳定为止:

iterations = 0
converged = False
while not converged:
    pi_ = np.copy(pi)
    for state in states_to_update:
        action = policy[state]
        V[state] = np.dot(transitions[action, state], 
                                      rewards[action, state] + gamma* V)
        pi = policy_improvement(V.copy(), transitions)
    if np.array_equal(pi_, pi):
        converged = True
    iterations += 1 

政策迭代在仅三次迭代后收敛。在算法找到最优值函数之前,策略会稳定下来,而最优策略略有不同,最明显的是建议在负终端状态旁边的场地上“向上”而不是更安全的“向左”。通过缩紧收敛标准(例如,要求几轮稳定的策略或为值函数添加阈值),可以避免这种情况。

使用 pymdptoolbox 解决 MDP 问题

我们也可以使用 Python 库pymdptoolbox来解决 MDP 问题,其中包括一些其他算法,包括 Q-learning。

要运行值迭代,只需在调用.run()方法之前,使用所需的配置选项、奖励和转移矩阵实例化相应的对象:

vi = mdp.ValueIteration(transitions=transitions,
                        reward=rewards,
                        discount=gamma,
                        epsilon=epsilon)
vi.run() 

值函数估计与上一节的结果相匹配:

np.allclose(V.reshape(grid_size), np.asarray(vi.V).reshape(grid_size)) 

政策迭代工作方式类似:

pi = mdp.PolicyIteration(transitions=transitions,
                        reward=rewards,
                        discount=gamma,
                        max_iter=1000)
pi.run() 

它也产生相同的策略,但是值函数会根据运行而变化,并且在策略收敛之前不需要达到最优值。

吸取的教训

我们之前在图 22.4中看到的右侧面板显示了值迭代产生的最优值估计以及相应的贪婪策略。负奖励与环境的不确定性相结合,产生了一个最优策略,涉及远离负终端状态。

结果对奖励和折扣因子都很敏感。负状态的成本影响周围字段的策略,您应修改相应笔记本中的示例以识别改变最优动作选择的阈值水平。

Q-learning - 边走边学习寻找最优策略

Q-learning 是早期的强化学习突破,由克里斯·沃特金斯(Chris Watkins)为他的博士论文开发(www.cs.rhul.ac.uk/~chrisw/new_thesis.pdf)(1989)。它引入了增量动态规划来学习控制 MDP,而不知道或建模我们在前一节中用于值和策略迭代的转移和奖励矩阵。 3 年后进行了收敛证明(Christopher J.C.H. Watkins 和 Dayan 1992)。

Q 学习直接优化动作值函数q以逼近*q**。学习进行“离策略”,即,算法不需要仅根据值函数隐含的策略选择动作。然而,收敛需要所有状态-动作对在整个训练过程中持续更新。确保这一点的一种简单方法是通过**-贪婪策略**。

探索与利用 – -贪婪策略

一个**-贪婪策略是一种简单的策略,它确保在给定状态下探索新的动作,同时也利用了学习经验。它通过随机选择动作来实现这一点。一个-贪婪策略**以概率随机选择一个动作,否则选择值函数最优的动作。

Q 学习算法

该算法在随机初始化后的给定数量的剧集中不断改进状态-动作值函数。在每个时间步长,它根据一个**-贪婪策略**选择一个动作,并使用学习率来更新值函数,如下所示:

请注意,该算法不会根据转移概率计算期望值。相反,它从**-贪婪策略**产生的奖励R[t]和下一个状态的折现值函数的当前估计中学习Q函数。

使用估计值函数来改进这个估计本身被称为自举。Q 学习算法是时间差TD学习算法的一部分。TD 学习不会等待收到剧集的最终奖励。相反,它使用更接近最终奖励的中间状态的值来更新其估计。在这种情况下,中间状态是一步。

如何使用 Python 训练一个 Q 学习智能体

在本节中,我们将演示如何使用上一节中的状态网格构建一个 Q 学习智能体。我们将训练智能体进行 2,500 个剧集,使用学习速率进行**-贪婪策略**(有关详细信息,请参见笔记本gridworld_q_learning.ipynb):

max_episodes = 2500
alpha = .1
epsilon = .05 

然后,我们将随机初始化状态-动作值函数作为 NumPy 数组,维度为状态数×动作数

Q = np.random.rand(num_states, num_actions)
Q[skip_states] = 0 

该算法生成 2,500 个从随机位置开始并根据**-贪婪策略**进行的剧集,直到终止,根据 Q 学习规则更新值函数:

for episode in range(max_episodes):
    state = np.random.choice([s for s in states if s not in skip_states])
    while not state in absorbing_states.keys():
        if np.random.rand() < epsilon:
            action = np.random.choice(num_actions)
        else:
            action = np.argmax(Q[state])
        next_state = np.random.choice(states, p=transitions[action, state])
        reward = rewards[action, state, next_state]
        Q[state, action] += alpha * (reward + 
                            gamma * np.max(Q[next_state])-Q[state, action])
        state = next_state 

每个情节需要 0.6 秒,并收敛到与前一节中值迭代示例结果相当接近的值函数。pymdptoolbox实现与以前的示例类似(详情请参见笔记本)。

使用 OpenAI Gym 进行交易的深度 RL

在前一节中,我们看到了 Q 学习如何让我们在具有离散状态和离散动作的环境中使用基于贝尔曼方程的迭代更新来学习最优状态-动作值函数*q**。

在本节中,我们将强化学习(RL)迈向真实世界,将算法升级为连续状态(同时保持动作离散)。这意味着我们不能再使用简单地填充数组状态-动作值的表格解决方案。相反,我们将看到如何使用神经网络来近似 q,从而得到深度 Q 网络。在介绍深度 Q 学习算法之前,我们将首先讨论深度学习与 RL 的整合,以及各种加速其收敛并使其更加健壮的改进。

连续状态还意味着更复杂的环境。我们将演示如何使用 OpenAI Gym,一个用于设计和比较 RL 算法的工具包。首先,我们将通过训练一个深度 Q 学习代理程序来演示工作流程,以在月球着陆器环境中导航一个玩具飞船。然后,我们将继续自定义 OpenAI Gym,设计一个模拟交易情境的环境,其中代理可以买卖股票,并与市场竞争。

使用神经网络进行值函数近似

连续状态和/或动作空间意味着无限数量的转换,使得不可能像前一节那样制表状态-动作值。相反,我们通过学习连续参数化映射来近似 Q 函数。

受到在其他领域中 NN 成功的启发,我们在Part 4中讨论过,深度 NN 也因近似值函数而变得流行起来。然而,在 RL 环境中,数据由模型与使用(可能是随机的)策略与环境进行交互生成,面临着不同的挑战

  • 对于连续状态,代理将无法访问大多数状态,因此需要进行泛化。

  • 在监督学习中,旨在从独立同分布且具有代表性且正确标记的样本中概括出来,而在强化学习(RL)环境中,每个时间步只有一个样本,因此学习需要在线进行。

  • 此外,当连续状态时,样本可能高度相关,当连续状态相似且行为分布在状态和动作上不是固定的,而是由于代理的学习而发生变化时,样本可能高度相关。

我们将介绍几种已开发的技术来解决这些额外的挑战。

深度 Q 学习算法及其扩展

深度 Q 学习通过深度神经网络估计给定状态的可用动作的价值。DeepMind 在 使用深度强化学习玩 Atari 游戏(Mnih 等人,2013)中介绍了这项技术,代理程序仅从像素输入中学习玩游戏。

深度 Q 学习算法通过学习一组权重 的多层深度 Q 网络DQN)来近似动作价值函数 q,该函数将状态映射到动作,使得

该算法应用基于损失函数的梯度下降,计算目标 DQN 的估计之间的平方差:

并根据当前状态-动作对的动作价值估计 来学习网络参数:

目标和当前估计都依赖于 DQN 权重,突显了与监督学习的区别,在监督学习中,目标在训练之前是固定的。

该 Q 学习算法不计算完整梯度,而是使用随机梯度下降SGD)并在每个时间步 i 后更新权重 。为了探索状态-动作空间,代理程序使用一个 -贪婪策略,以概率选择一个随机动作,否则按照最高预测 q 值选择动作。

基本的DQN 架构已经得到改进,以使学习过程更加高效,并改善最终结果;Hessel 等人(2017)将这些创新组合成Rainbow 代理,并展示了每个创新如何显著提高 Atari 基准测试的性能。以下各小节总结了其中一些创新。

(优先)经验回放 – 关注过去的错误

经验回放存储代理程序经历的状态、动作、奖励和下一个状态转换的历史记录。它从这些经验中随机抽样小批量,在每个时间步更新网络权重,然后代理程序选择一个ε-贪婪动作。

经验回放提高了样本效率,减少了在线学习期间收集的样本的自相关性,并限制了由当前权重产生的反馈,这些反馈可能导致局部最小值或发散(Lin 和 Mitchell 1992)。

此技术后来被进一步改进,以优先考虑从学习角度更重要的经验。Schaul 等人(2015)通过 TD 误差的大小来近似转换的价值,该误差捕捉了该事件对代理程序的“惊讶程度”。实际上,它使用其关联的 TD 误差而不是均匀概率对历史状态转换进行抽样。

目标网络 – 解耦学习过程

为了进一步削弱当前网络参数对 NN 权重更新的反馈循环,DeepMind 在 Human-level control through deep reinforcement learning(Mnih et al. 2015)中将算法扩展为使用缓慢变化的目标网络。

目标网络具有与 Q 网络相同的架构,但其权重为 ,仅在每隔 步更新一次,当它们从 Q 网络复制并保持不变时。目标网络生成 TD 目标预测,即它取代 Q 网络来估计:

双深度 Q 学习 – 分离行动和预测

Q-learning 存在过高估计行动价值的问题,因为它有意地采样最大估计行动价值。

如果这种偏见不是均匀应用并且改变行动偏好,它可能会对学习过程和结果的政策产生负面影响,就像 Deep Reinforcement Learning with Double Q-learning(van Hasselt, Guez, and Silver 2015)中所示的那样。

为了将行动价值的估计与行动的选择分离,双深度 Q 学习DDQN)算法使用一个网络的权重 来选择给定下一个状态的最佳行动,以及另一个网络的权重 来提供相应的行动价值估计:

一个选项是在每次迭代时随机选择两个相同网络中的一个进行训练,以使它们的权重不同。更有效的替代方法是依靠目标网络提供

介绍 OpenAI Gym

OpenAI Gym 是一个提供标准化环境以测试和基准 RL 算法的 RL 平台,使用 Python。也可以扩展该平台并注册自定义环境。

Lunar Lander v2LL)环境要求代理根据离散行动空间和包括位置、方向和速度在内的低维状态观察来控制其在二维中的运动。在每个时间步长,环境提供新状态的观察和正面或负面的奖励。每个事件最多包含 1,000 个时间步。图 22.5 展示了我们稍后将训练的代理在经过 250 个事件后成功着陆时的选定帧:

图 22.5:月球着陆器(Lunar Lander)事件期间 RL 代理的行为

更具体地说,代理观察到状态的八个方面,包括六个连续和两个离散元素。根据观察到的元素,代理知道自己的位置、方向和移动速度,以及是否(部分)着陆。然而,它不知道应该朝哪个方向移动,也不能观察环境的内部状态以了解规则来控制其运动。

在每个时间步长,智能体使用四种离散动作来控制其运动。它可以什么都不做(继续当前路径),启动主引擎(减少向下运动),或使用相应的方向引擎向左或向右转向。没有燃料限制。

目标是在坐标(0,0)的着陆垫上的两个旗帜之间着陆智能体,但也可以在垫子外着陆。智能体朝着垫子移动,积累的奖励在 100-140 之间,具体取决于着陆点。然而,远离目标的移动会抵消智能体通过朝着垫子移动而获得的奖励。每条腿的接地都会增加 10 分,而使用主引擎会消耗-0.3 点。

如果智能体着陆或坠毁,则一集结束,分别添加或减去 100 分,或在 1000 个时间步之后结束。解决 LL 需要在 100 个连续集合上平均获得至少 200 的累积奖励。

如何使用 TensorFlow 2 实现 DDQN

笔记本03_lunar_lander_deep_q_learning使用 TensorFlow 2 实现了一个 DDQN 代理程序,该程序学习解决 OpenAI Gym 的月球着陆器2.0(LL)环境。笔记本03_lunar_lander_deep_q_learning包含了在第一版中讨论的 TensorFlow 1 实现,运行速度显著更快,因为它不依赖于急切执行,并且更快地收敛。本节重点介绍了实现的关键元素;更详细的细节请参阅笔记本。

创建 DDQN 代理

我们将DDQNAgent创建为一个 Python 类,以将学习和执行逻辑与关键配置参数和性能跟踪集成在一起。

代理的__init__()方法接受以下信息作为参数:

  • 环境特征,比如状态观测的维度数量以及智能体可用的动作数量。

  • ε-贪婪策略的随机探索衰减。

  • 神经网络架构训练和目标网络更新的参数。

    class DDQNAgent:
        def __init__(self, state_dim, num_actions, gamma,
                     epsilon_start, epsilon_end, epsilon_decay_steps,
                     epsilon_exp_decay,replay_capacity, learning_rate,
                     architecture, l2_reg, tau, batch_size,
                     log_dir='results'): 
    

将 DDQN 架构调整为月球着陆器

首次将 DDQN 架构应用于具有高维图像观察的雅达利领域,并依赖于卷积层。LL 的较低维状态表示使得全连接层成为更好的选择(见第十七章交易深度学习)。

更具体地说,网络将八个输入映射到四个输出,表示每个动作的 Q 值,因此只需进行一次前向传递即可计算动作值。DQN 使用 Adam 优化器对先前的损失函数进行训练。代理的 DQN 使用每个具有 256 单元的三个密集连接层和 L2 活动正则化。通过 TensorFlow Docker 镜像使用 GPU 可以显著加快 NN 训练性能(见第十七章第十八章金融时间序列和卫星图像的 CNN)。

DDQNAgent类的build_model()方法根据architecture参数创建主要的在线和缓慢移动的目标网络,该参数指定了层的数量和它们的单元数量。

对于主要的在线网络,我们将trainable设置为True,对于目标网络,我们将其设置为False。这是因为我们只是周期性地将在线 NN 的权重复制以更新目标网络:

 def build_model(self, trainable=True):
        layers = []
        for i, units in enumerate(self.architecture, 1):
            layers.append(Dense(units=units,
                                input_dim=self.state_dim if i == 1 else None,
                                activation='relu',
                                kernel_regularizer=l2(self.l2_reg),
                                trainable=trainable))
        layers.append(Dense(units=self.num_actions, 
                            trainable=trainable))
        model = Sequential(layers)
        model.compile(loss='mean_squared_error',
                      optimizer=Adam(lr=self.learning_rate))
        return model 

记忆转换和重播体验

为了启用经验重播,代理记忆每个状态转换,以便在训练期间随机抽样小批量。memorize_transition()方法接收环境提供的当前和下一个状态的观察、代理的动作、奖励以及指示情节是否完成的标志。

它跟踪奖励历史和每个情节的长度,在每个周期结束时对 epsilon 进行指数衰减,并将状态转换信息存储在缓冲区中:

 def memorize_transition(self, s, a, r, s_prime, not_done):
        if not_done:
            self.episode_reward += r
            self.episode_length += 1
        else:
            self.episodes += 1
            self.rewards_history.append(self.episode_reward)
            self.steps_per_episode.append(self.episode_length)
            self.episode_reward, self.episode_length = 0, 0
        self.experience.append((s, a, r, s_prime, not_done)) 

一旦有足够的样本创建完整的批次,记忆的重播就开始了。experience_replay()方法使用在线网络预测下一个状态的 Q 值,并选择最佳动作。然后,它从目标网络中选择这些动作的预测q值,以得到 TDtargets

接下来,它使用单个批次的当前状态观察作为输入,TD 目标作为输出,并将均方误差作为损失函数训练主要网络。最后,它每隔步更新一次目标网络的权重:

 def experience_replay(self):
        if self.batch_size > len(self.experience):
            return
        # sample minibatch from experience
        minibatch = map(np.array, zip(*sample(self.experience, 
                                              self.batch_size)))
        states, actions, rewards, next_states, not_done = minibatch
        # predict next Q values to select best action
        next_q_values = self.online_network.predict_on_batch(next_states)
        best_actions = tf.argmax(next_q_values, axis=1)
        # predict the TD target
        next_q_values_target = self.target_network.predict_on_batch(
            next_states)
        target_q_values = tf.gather_nd(next_q_values_target,
                                       tf.stack((self.idx, tf.cast(
                                          best_actions, tf.int32)), axis=1))
        targets = rewards + not_done * self.gamma * target_q_values
        # predict q values
        q_values = self.online_network.predict_on_batch(states)
        q_values[[self.idx, actions]] = targets
        # train model
        loss = self.online_network.train_on_batch(x=states, y=q_values)
        self.losses.append(loss)
        if self.total_steps % self.tau == 0:
            self.update_target()
    def update_target(self):
        self.target_network.set_weights(self.online_network.get_weights()) 

笔记本包含ε-贪心策略和目标网络权重更新的其他实现细节。

设置 OpenAI 环境

我们将首先实例化并从 LL 环境中提取关键参数:

env = gym.make('LunarLander-v2')
state_dim = env.observation_space.shape[0]  # number of dimensions in state
num_actions = env.action_space.n  # number of actions
max_episode_steps = env.spec.max_episode_steps  # max number of steps per episode
env.seed(42) 

我们还将使用内置的包装器,允许周期性地存储显示代理性能的视频:

from gym import wrappers
env = wrappers.Monitor(env,
                       directory=monitor_path.as_posix(),
                       video_callable=lambda count: count % video_freq == 0,
                      force=True) 

在没有显示器的服务器或 Docker 容器上运行时,您可以使用pyvirtualdisplay

关键的超参数选择

代理的性能对几个超参数非常敏感。我们将从折扣率和学习率开始:

gamma=.99,  # discount factor
learning_rate=1e-4  # learning rate 

我们将每隔 100 个时间步更新目标网络,在回放内存中存储最多 100 万个过去的情节,并从内存中对训练代理进行 1,024 个小批量的抽样:

tau=100  # target network update frequency
replay_capacity=int(1e6)
batch_size = 1024 

ε-贪心策略从纯探索开始,线性衰减至,然后在 250 个情节后以指数衰减:

epsilon_start=1.0
epsilon_end=0.01
epsilon_linear_steps=250
epsilon_exp_decay=0.99 

笔记本包含训练循环,包括经验重播、SGD 和缓慢的目标网络更新。

月球着陆器的学习表现

前述的超参数设置使得代理能够在约 300 个情节内使用 TensorFlow 1 实现解决环境。

图 22.6的左侧面板显示了剧集奖励及其 100 个周期移动平均值。右侧面板显示了探索的衰减和每个剧集的步数。通常有一段约 100 个剧集的拉伸,每个剧集通常需要 1,000 个时间步长,而代理减少探索并在开始相当一致地着陆之前“学会如何飞行”:

图 22.6:DDQN 代理在月球着陆环境中的表现

创建一个简单的交易代理

在本节和以下节中,我们将调整深度 RL 方法来设计一个学习如何交易单个资产的代理。为了训练代理,我们将建立一个简单的环境,其中包含一组有限的操作,具有连续观测的相对较低维度状态和其他参数。

更具体地说,环境使用随机开始日期对单个标的的股票价格时间序列进行抽样,以模拟一个交易期,默认情况下包含 252 天或 1 年。每个状态观察为代理人提供了不同滞后期的历史回报以及一些技术指标,如相对强度指数RSI)。

代理可以选择三种操作

  • 买入:将所有资本投资于股票的多头头寸。

  • 平仓:仅持有现金。

  • 卖空:做出等于资本金额的空头头寸。

环境考虑交易成本,默认设置为 10 个基点,并在没有交易的情况下每周期扣除一个基点。代理的奖励包括每日回报减去交易成本。

环境跟踪代理人投资组合(由单只股票组成)的净资产价值NAV),并将其与市场投资组合进行比较,后者无摩擦地交易以提高代理人的门槛。

一个剧集从起始 NAV 为 1 单位现金开始:

  • 如果 NAV 降至 0,剧集以损失结束。

  • 如果 NAV 达到 2.0,代理人就赢了。

此设置限制了复杂性,因为它专注于单只股票,并从头寸大小抽象出来,以避免需要连续操作或更多离散操作,以及更复杂的簿记。但是,它对于演示如何定制环境并允许扩展是有用的。

如何设计自定义 OpenAI 交易环境

要构建一个学习如何交易的代理,我们需要创建一个市场环境,提供价格和其他信息,提供相关的行动,并跟踪投资组合以相应地奖励代理人。有关构建大规模、现实世界模拟环境的努力的描述,请参见 Byrd、Hybinette 和 Balch(2019)。

OpenAI Gym 允许设计、注册和使用符合其体系结构的环境,如文档中所述。文件trading_env.py包含以下代码示例,除非另有说明。

交易环境由三个类组成,这些类相互作用以促进代理的活动。 DataSource 类加载时间序列,生成一些特征,并在每个时间步骤将最新观察结果提供给代理。 TradingSimulator 跟踪位置、交易和成本,以及性能。它还实现并记录了买入持有基准策略的结果。 TradingEnvironment 本身编排整个过程。我们将依次简要描述每个类;有关实现细节,请参阅脚本。

设计 DataSource

首先,我们编写一个 DataSource 类来加载和预处理历史股票数据,以创建用于状态观察和奖励的信息。在本例中,我们将保持非常简单,为代理提供一支股票的历史数据。或者,您可以将许多股票合并成一个时间序列,例如,以训练代理交易标准普尔 500 成分股。

我们将加载从早期到 2018 年的 Quandl 数据集中一个股票的调整价格和成交量信息,本例中为 AAPL:

class DataSource:
    """Data source for TradingEnvironment
    Loads & preprocesses daily price & volume data
    Provides data for each new episode.
    """
    def __init__(self, trading_days=252, ticker='AAPL'):
        self.ticker = ticker
        self.trading_days = trading_days
    def load_data(self):
        idx = pd.IndexSlice
        with pd.HDFStore('../data/assets.h5') as store:
            df = (store['quandl/wiki/prices']
                  .loc[idx[:, self.ticker],
                       ['adj_close', 'adj_volume', 'adj_low', 'adj_high']])
        df.columns = ['close', 'volume', 'low', 'high']
        return df 

preprocess_data() 方法创建多个特征并对其进行归一化。最近的日回报起着两个作用:

  • 当前状态的观察元素

  • 上个周期的交易成本净额,以及根据仓位大小而定的奖励

方法采取了以下步骤,其中包括(有关技术指标的详细信息,请参见 附录):

def preprocess_data(self):
"""calculate returns and percentiles, then removes missing values"""
   self.data['returns'] = self.data.close.pct_change()
   self.data['ret_2'] = self.data.close.pct_change(2)
   self.data['ret_5'] = self.data.close.pct_change(5)
   self.data['rsi'] = talib.STOCHRSI(self.data.close)[1]
   self.data['atr'] = talib.ATR(self.data.high, 
                                self.data.low, self.data.close)
   self.data = (self.data.replace((np.inf, -np.inf), np.nan)
                .drop(['high', 'low', 'close'], axis=1)
                .dropna())
   if self.normalize:
       self.data = pd.DataFrame(scale(self.data),
                                columns=self.data.columns,
                                index=self.data.index) 

DataSource 类跟踪每一集的进度,在每个时间步骤为 TradingEnvironment 提供新鲜数据,并在每一集结束时发出信号:

def take_step(self):
    """Returns data for current trading day and done signal"""
    obs = self.data.iloc[self.offset + self.step].values
    self.step += 1
    done = self.step > self.trading_days
    return obs, done 

TradingSimulator

交易模拟器计算代理的奖励并跟踪代理和“市场”的净资产价值,后者执行具有再投资的买入持有策略。它还跟踪仓位和市场回报,计算交易成本并记录结果。

该类最重要的方法是 take_step 方法,根据当前位置、最新的股票回报和交易成本计算代理的奖励(略有简化;有关完整细节,请参阅脚本):

def take_step(self, action, market_return):
    """ Calculates NAVs, trading costs and reward
        based on an action and latest market return
        returns the reward and an activity summary"""
    start_position = self.positions[max(0, self.step - 1)]
    start_nav = self.navs[max(0, self.step - 1)]
    start_market_nav = self.market_navs[max(0, self.step - 1)]
    self.market_returns[self.step] = market_return
    self.actions[self.step] = action
    end_position = action - 1 # short, neutral, long
    n_trades = end_position – start_position
    self.positions[self.step] = end_position
    self.trades[self.step] = n_trades
    time_cost = 0 if n_trades else self.time_cost_bps
    self.costs[self.step] = abs(n_trades) * self.trading_cost_bps + time_cost
    if self.step > 0:
        reward = start_position * market_return - self.costs[self.step-1]
        self.strategy_returns[self.step] = reward
        self.navs[self.step] = start_nav * (1 + 
                                            self.strategy_returns[self.step])
        self.market_navs[self.step] = start_market_nav * (1 + 
                                            self.market_returns[self.step])
    self.step += 1
    return reward 

TradingEnvironment

TradingEnvironment 类是 gym.Env 的子类,驱动环境动态。它实例化 DataSourceTradingSimulator 对象,并设置动作和状态空间的维度,后者取决于 DataSource 定义的特征范围:

class TradingEnvironment(gym.Env):
    """A simple trading environment for reinforcement learning.
    Provides daily observations for a stock price series
    An episode is defined as a sequence of 252 trading days with random start
    Each day is a 'step' that allows the agent to choose one of three actions.
    """
    def __init__(self, trading_days=252, trading_cost_bps=1e-3,
                 time_cost_bps=1e-4, ticker='AAPL'):
        self.data_source = DataSource(trading_days=self.trading_days,
                                      ticker=ticker)
        self.simulator = TradingSimulator(
                steps=self.trading_days,
                trading_cost_bps=self.trading_cost_bps,
                time_cost_bps=self.time_cost_bps)
        self.action_space = spaces.Discrete(3)
        self.observation_space = spaces.Box(self.data_source.min_values,
                                            self.data_source.max_values) 

TradingEnvironment 的两个关键方法是 .reset().step()。前者初始化 DataSourceTradingSimulator 实例,如下所示:

def reset(self):
    """Resets DataSource and TradingSimulator; returns first observation"""
    self.data_source.reset()
    self.simulator.reset()
    return self.data_source.take_step()[0] 

每个时间步骤依赖于 DataSourceTradingSimulator 提供状态观察并奖励最近的动作:

def step(self, action):
    """Returns state observation, reward, done and info"""
    assert self.action_space.contains(action), 
      '{} {} invalid'.format(action, type(action))
    observation, done = self.data_source.take_step()
    reward, info = self.simulator.take_step(action=action,
                                            market_return=observation[0])
    return observation, reward, done, info 

注册和参数化自定义环境

在使用自定义环境之前,就像对待月球着陆器环境一样,我们需要将其注册到 gym 包中,提供关于 entry_point 的信息,即模块和类,并定义每个剧集的最大步数(以下步骤发生在 q_learning_for_trading 笔记本中):

from gym.envs.registration import register
register(
        id='trading-v0',
        entry_point='trading_env:TradingEnvironment',
        max_episode_steps=252) 

我们可以使用所需的交易成本和股票代码实例化环境:

trading_environment = gym.make('trading-v0')
trading_environment.env.trading_cost_bps = 1e-3
trading_environment.env.time_cost_bps = 1e-4
trading_environment.env.ticker = 'AAPL'
trading_environment.seed(42) 

股票市场上的深度 Q 学习

笔记本 q_learning_for_trading 包含 DDQN 代理训练代码;我们只会突出显示与先前示例有显著不同的地方。

调整和训练 DDQN 代理

我们将使用相同的 DDQN 代理,但简化 NN 架构为每层 64 个单元的两层,并添加了用于正则化的 dropout。在线网络有 5059 个可训练参数:

Layer (type)                 Output Shape              Param #   
Dense_1 (Dense)              (None, 64)                704       
Dense_2 (Dense)              (None, 64)                4160      
dropout (Dropout)            (None, 64)                0         
Output (Dense)               (None, 3)                 195       
Total params: 5,059
Trainable params: 5,059 

训练循环与自定义环境的交互方式与月球着陆器案例非常相似。当剧集处于活动状态时,代理根据其当前策略采取行动,并在记忆当前转换后使用经验回放来训练在线网络。以下代码突出显示了关键步骤:

for episode in range(1, max_episodes + 1):
    this_state = trading_environment.reset()
    for episode_step in range(max_episode_steps):
        action = ddqn.epsilon_greedy_policy(this_state.reshape(-1, 
                                                               state_dim))
        next_state, reward, done, _ = trading_environment.step(action)

        ddqn.memorize_transition(this_state, action,
                                 reward, next_state,
                                 0.0 if done else 1.0)
        ddqn.experience_replay()
        if done:
            break
        this_state = next_state
trading_environment.close() 

我们让探索持续进行 2000 个 1 年的交易周期,相当于约 500,000 个时间步;我们在 500 个周期内使用 ε 的线性衰减从 1.0 到 0.1,之后以指数衰减因子 0.995 进行指数衰减。

基准 DDQN 代理的表现

为了比较 DDQN 代理的表现,我们不仅追踪买入持有策略,还生成一个随机代理的表现。

图 22.7 显示了 2000 个训练周期(左侧面板)中最近 100 个剧集的三个累积回报值的滚动平均值,以及代理超过买入持有期的最近 100 个剧集的份额(右侧面板)。它使用了 AAPL 股票数据,其中包含约 9000 个每日价格和交易量观测值:

图 22.7:交易代理的表现相对于市场

这显示了代理在 500 个剧集后的表现稳步提高,从随机代理的水平开始,并在实验结束时开始超过买入持有策略超过一半的时间。

学到的经验

这个相对简单的代理程序除了最新的市场数据和奖励信号外,没有使用其他信息,与我们在本书其他部分介绍的机器学习模型相比。尽管如此,它学会了盈利,并且在训练了 2000 年的数据之后,它的表现与市场相似(在 GPU 上只需花费一小部分时间)。

请记住,只使用一支股票也会增加过拟合数据的风险——相当多。您可以使用保存的模型在新数据上测试您训练过的代理(请参阅月球着陆器的笔记本)。

总之,我们演示了建立 RL 交易环境的机制,并尝试了一个使用少量技术指标的基本代理。你应该尝试扩展环境和代理,例如从多个资产中选择、确定头寸大小和管理风险。

强化学习通常被认为是算法交易中最有前途的方法,因为它最准确地模拟了投资者所面临的任务。然而,我们大大简化的示例说明了创建一个真实环境的巨大挑战。此外,已在其他领域取得了令人印象深刻突破的深度强化学习可能会面临更大的障碍,因为金融数据的噪声性质使得基于延迟奖励学习价值函数更加困难。

尽管如此,对这一主题的巨大兴趣使得机构投资者很可能正在进行规模更大的实验,这些实验可能会产生实质性的结果。这本书范围之外的一个有趣的补充方法是逆强化学习,它旨在确定一个代理的奖励函数(例如,一个人类交易者)给出其观察到的行为;参见 Arora 和 Doshi(2019)进行调查以及 Roa-Vicens 等人(2019)在限价订单簿环境中应用的应用。

摘要

在本章中,我们介绍了一类不同的机器学习问题,重点是通过与环境交互的代理自动化决策。我们介绍了定义 RL 问题和各种解决方法所需的关键特征。

我们看到了如何将 RL 问题框定为有限马尔可夫决策问题,并且如何使用价值和策略迭代来计算解决方案。然后我们转向更现实的情况,其中转移概率和奖励对代理来说是未知的,并且看到了 Q-learning 如何基于马尔可夫决策问题中由贝尔曼最优方程定义的关键递归关系。我们看到了如何使用 Python 来解决简单 MDP 和更复杂环境的 RL 问题,并使用 Q-learning。

然后我们扩大了范围到连续状态,并将深度 Q-learning 算法应用于更复杂的 Lunar Lander 环境。最后,我们使用 OpenAI Gym 平台设计了一个简单的交易环境,并演示了如何训练代理学习在交易单个股票时如何盈利。

在下一章中,我们将从本书的旅程中得出一些结论和关键收获,并提出一些步骤供您考虑,以便在继续发展您的机器学习用于交易的技能时使用。